feat(alerting): Implement alert-level provider overrides (#929)

* feat(alerting): Implement alert-level provider overrides

Fixes #96

* Fix tests

* Add missing test cases for alerting providers

* feat(alerting): Implement alert-level overrides on all providers

* chore: Add config.yaml to .gitignore

* fix typo in discord provider

* test: Start fixing tests for alerting providers

* test: Fix GitLab tests

* Fix all tests

* test: Improve coverage

* test: Improve coverage

* Rename override to provider-override

* docs: Mention new provider-override config

* test: Improve coverage

* test: Improve coverage

* chore: Rename Alert.OverrideAsBytes to Alert.ProviderOverrideAsBytes
This commit is contained in:
TwiN 2024-12-16 20:32:13 -05:00 committed by GitHub
parent be9ae6f55d
commit 79c9f24c15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 4623 additions and 2109 deletions

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ node_modules
*.db-wal *.db-wal
gatus gatus
config/config.yml config/config.yml
config.yaml

View File

@ -72,8 +72,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Telegram alerts](#configuring-telegram-alerts) - [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Configuring Zulip alerts](#configuring-zulip-alerts) - [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert) - [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance) - [Maintenance](#maintenance)
- [Security](#security) - [Security](#security)
@ -548,14 +548,29 @@ endpoints:
send-on-resolved: true send-on-resolved: true
``` ```
You can also override global provider configuration by using `alerts[].provider-override`, like so:
```yaml
endpoints:
- name: example
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
provider-override:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
```
> 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be > 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be
> ignored. > ignored.
| Parameter | Description | Default | | Parameter | Description | Default |
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------| |:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` | | `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | | `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` | | `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.gitea` | Configuration for alerts of type `gitea`. <br />See [Configuring Gitea alerts](#configuring-gitea-alerts). | `{}` |
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` | | `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` | | `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` | | `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
@ -573,6 +588,7 @@ endpoints:
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` | | `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | | `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.zulip` | Configuration for alerts of type `zulip`. <br />See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
#### Configuring Discord alerts #### Configuring Discord alerts
@ -785,14 +801,13 @@ endpoints:
#### Configuring Google Chat alerts #### Configuring Google Chat alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------| |:----------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` | | `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` | | `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` | | `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A | | `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.googlechat.overrides[].webhook-url` | Google Chat Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
@ -925,7 +940,6 @@ endpoints:
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A | | `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.mattermost.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.mattermost.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.mattermist.overrides[].webhook-url` | Mattermost Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
@ -1332,8 +1346,6 @@ Here's an example of what the notifications look like:
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.telegram.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.telegram.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.telegram.overrides[].token` | Telegram Bot Token for override default value | `""` |
| `alerting.telegram.overrides[].id` | Telegram User ID for override default value | `""` |
```yaml ```yaml
alerting: alerting:
@ -1431,6 +1443,39 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b
Make sure you have the ability to use `ses:SendEmail`. Make sure you have the ability to use `ses:SendEmail`.
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
| `alerting.zulip.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
```yaml
alerting:
zulip:
bot-email: gatus-bot@some.zulip.org
bot-api-key: "********************************"
domain: some.zulip.org
channel-id: 123456
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: zulip
description: "healthcheck failed"
send-on-resolved: true
```
#### Configuring custom alerts #### Configuring custom alerts
| Parameter | Description | Default | | Parameter | Description | Default |
@ -1591,42 +1636,6 @@ endpoints:
- type: pagerduty - type: pagerduty
``` ```
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------------|:------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.zulip.overrides[].bot-email` | . | `""` |
| `alerting.zulip.overrides[].bot-api-key` | . | `""` |
| `alerting.zulip.overrides[].domain` | . | `""` |
| `alerting.zulip.overrides[].channel-id` | . | `""` |
```yaml
alerting:
zulip:
bot-email: gatus-bot@some.zulip.org
bot-api-key: "********************************"
domain: some.zulip.org
channel-id: 123456
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: zulip
description: "healthcheck failed"
send-on-resolved: true
```
### Maintenance ### Maintenance
If you have maintenance windows, you may not want to be annoyed by alerts. If you have maintenance windows, you may not want to be annoyed by alerts.

View File

@ -6,6 +6,9 @@ import (
"errors" "errors"
"strconv" "strconv"
"strings" "strings"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
) )
var ( var (
@ -36,13 +39,17 @@ type Alert struct {
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. // or not for provider.ParseWithDefaultAlert to work.
Description *string `yaml:"description"` Description *string `yaml:"description,omitempty"`
// SendOnResolved defines whether to send a second notification when the issue has been resolved // SendOnResolved defines whether to send a second notification when the issue has been resolved
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer // or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
SendOnResolved *bool `yaml:"send-on-resolved"` SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
// ProviderOverride is an optional field that can be used to override the provider's configuration
// It is freeform so that it can be used for any provider-specific configuration.
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve // ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents // ongoing/triggered incidents
@ -111,3 +118,11 @@ func (alert *Alert) Checksum() string {
) )
return hex.EncodeToString(hash.Sum(nil)) return hex.EncodeToString(hash.Sum(nil))
} }
func (alert *Alert) ProviderOverrideAsBytes() []byte {
yamlBytes, err := yaml.Marshal(alert.ProviderOverride)
if err != nil {
logr.Warnf("[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v", alert.Type, err)
}
return yamlBytes
}

View File

@ -1,30 +1,73 @@
package awsses package awsses
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses" "github.com/aws/aws-sdk-go/service/ses"
"gopkg.in/yaml.v3"
) )
const ( const (
CharSet = "UTF-8" CharSet = "UTF-8"
) )
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service var (
type AlertProvider struct { ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
)
type Config struct {
AccessKeyID string `yaml:"access-key-id"` AccessKeyID string `yaml:"access-key-id"`
SecretAccessKey string `yaml:"secret-access-key"` SecretAccessKey string `yaml:"secret-access-key"`
Region string `yaml:"region"` Region string `yaml:"region"`
From string `yaml:"from"` From string `yaml:"from"`
To string `yaml:"to"` To string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
// otherwise if neither are specified, then we'll fall back on IAM authentication.
return ErrInvalidAWSAuthConfig
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKeyID) > 0 {
cfg.AccessKeyID = override.AccessKeyID
}
if len(override.SecretAccessKey) > 0 {
cfg.SecretAccessKey = override.SecretAccessKey
}
if len(override.Region) > 0 {
cfg.Region = override.Region
}
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 AWS Simple Email Service
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -36,35 +79,36 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
To string `yaml:"to"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate, return provider.DefaultConfig.Validate()
// otherwise if neither are specified, then we'll fall back on IAM authentication.
return len(provider.From) > 0 && len(provider.To) > 0 &&
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
sess, err := provider.createSession() cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil { if err != nil {
return err return err
} }
svc := ses.New(sess) awsSession, err := provider.createSession(cfg)
if err != nil {
return err
}
svc := ses.New(awsSession)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(provider.getToForGroup(ep.Group), ",") emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{ input := &ses.SendEmailInput{
Destination: &ses.Destination{ Destination: &ses.Destination{
@ -82,26 +126,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
Data: aws.String(subject), Data: aws.String(subject),
}, },
}, },
Source: aws.String(provider.From), Source: aws.String(cfg.From),
} }
_, err = svc.SendEmail(input) if _, err = svc.SendEmail(input); err != nil {
if err != nil {
if aerr, ok := err.(awserr.Error); ok { if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() { switch aerr.Code() {
case ses.ErrCodeMessageRejected: case ses.ErrCodeMessageRejected:
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error()) logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException: case ses.ErrCodeMailFromDomainNotVerifiedException:
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error()) logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException: case ses.ErrCodeConfigurationSetDoesNotExistException:
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error()) logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default: default:
fmt.Println(aerr.Error()) logr.Error(aerr.Error())
} }
} else { } else {
// Print the error, cast err to awserr.Error to get the Code and // Print the error, cast err to awserr.Error to get the Code and
// Message from an error. // Message from an error.
fmt.Println(err.Error()) logr.Error(err.Error())
} }
return err return err
@ -109,6 +151,16 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return nil return nil
} }
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
awsConfig := &aws.Config{
Region: aws.String(cfg.Region),
}
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
}
return session.NewSession(awsConfig)
}
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string var subject, message string
@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
return subject, message + description + formattedConditionResults return subject, message + description + formattedConditionResults
} }
// getToForGroup returns the appropriate email integration to for a given group
func (provider *AlertProvider) getToForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
}
}
return provider.To
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
func (provider *AlertProvider) createSession() (*session.Session, error) { // GetConfig returns the configuration for the provider with the overrides applied
config := &aws.Config{ func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
Region: aws.String(provider.Region), cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
} }
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
} }
return session.NewSession(config) }
// 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

@ -7,59 +7,61 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"} invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
if invalidProviderWithOneKey.IsValid() { if err := invalidProviderWithOneKey.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"} validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
if !validProviderWithKeys.IsValid() { if err := validProviderWithKeys.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "", Config: Config{To: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
From: "from@example.com", From: "from@example.com",
To: "to@example.com", To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -124,64 +126,124 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) { func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com", To: "to@example.com",
},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com", To: "to@example.com",
},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com", To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "groupto@example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com", To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to01@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
},
{
Name: "provider-with-override-specify-group-but-alert-override-should-override-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "sekrit"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{
ProviderOverride: map[string]any{
"to": "alertto@example.com",
"access-key-id": 123,
},
},
ExpectedOutput: Config{To: "alertto@example.com", From: "from@example.com", AccessKeyID: "123", SecretAccessKey: "sekrit"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
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)
}
if got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {
t.Errorf("expected AccessKeyID to be %s, got %s", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)
}
if got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {
t.Errorf("expected SecretAccessKey to be %s, got %s", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)
}
if got.Region != scenario.ExpectedOutput.Region {
t.Errorf("expected Region to be %s, got %s", scenario.ExpectedOutput.Region, got.Region)
}
// 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

@ -2,6 +2,7 @@ package custom
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,11 +11,14 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request var (
// Technically, all alert providers should be reachable using the custom alert provider ErrURLNotSet = errors.New("url not set")
type AlertProvider struct { )
type Config struct {
URL string `yaml:"url"` URL string `yaml:"url"`
Method string `yaml:"method,omitempty"` Method string `yaml:"method,omitempty"`
Body string `yaml:"body,omitempty"` Body string `yaml:"body,omitempty"`
@ -23,66 +27,66 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.Method) > 0 {
cfg.Method = override.Method
}
if len(override.Body) > 0 {
cfg.Body = override.Body
}
if len(override.Headers) > 0 {
cfg.Headers = override.Headers
}
if len(override.Placeholders) > 0 {
cfg.Placeholders = override.Placeholders
}
}
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
// Technically, all alert providers should be reachable using the custom alert provider
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` 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"`
} }
// IsValid returns whether the provider's configuration is valid // Override is a case under which the default integration is overridden
func (provider *AlertProvider) IsValid() bool { type Override struct {
if provider.ClientConfig == nil { Group string `yaml:"group"`
provider.ClientConfig = client.GetDefaultConfig() Config `yaml:",inline"`
}
return len(provider.URL) > 0 && provider.ClientConfig != nil
} }
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured // Validate the provider's configuration
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string { func (provider *AlertProvider) Validate() error {
status := "TRIGGERED" return provider.DefaultConfig.Validate()
if resolved {
status = "RESOLVED"
}
if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
return val
}
}
return status
}
func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers {
request.Header.Set(k, v)
}
return request
} }
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
request := provider.buildHTTPRequest(ep, alert, result, resolved) cfg, err := provider.GetConfig(ep.Group, alert)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) if err != nil {
return err
}
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@ -94,7 +98,82 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err return err
} }
func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := cfg.Body, cfg.URL, cfg.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range cfg.Headers {
request.Header.Set(k, v)
}
return request
}
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(cfg *Config, resolved bool) string {
status := "TRIGGERED"
if resolved {
status = "RESOLVED"
}
if _, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
if val, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
return val
}
}
return status
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -12,24 +12,18 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{URL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{URL: "https://example.com"} validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
}) })
} }
@ -47,7 +41,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -111,9 +105,11 @@ func TestAlertProvider_Send(t *testing.T) {
} }
func TestAlertProvider_buildHTTPRequest(t *testing.T) { func TestAlertProvider_buildHTTPRequest(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
},
} }
alertDescription := "alert-description" alertDescription := "alert-description"
scenarios := []struct { scenarios := []struct {
@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
ExpectedBody string ExpectedBody string
}{ }{
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: true, Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
}, },
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: false, Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
@ -137,7 +133,8 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}}, &endpoint.Result{Errors: []string{}},
@ -155,9 +152,11 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
} }
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) { func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
customAlertWithErrorsProvider := &AlertProvider{ alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
},
} }
alertDescription := "alert-description" alertDescription := "alert-description"
scenarios := []struct { scenarios := []struct {
@ -168,13 +167,13 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
Errors []string Errors []string
}{ }{
{ {
AlertProvider: customAlertWithErrorsProvider, AlertProvider: alertProvider,
Resolved: true, Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
}, },
{ {
AlertProvider: customAlertWithErrorsProvider, AlertProvider: alertProvider,
Resolved: false, Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
@ -183,7 +182,8 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
request := customAlertWithErrorsProvider.buildHTTPRequest( request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors}, &endpoint.Result{Errors: scenario.Errors},
@ -201,7 +201,8 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
} }
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) { func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil, Headers: nil,
@ -211,6 +212,7 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
"TRIGGERED": "boom", "TRIGGERED": "boom",
}, },
}, },
},
} }
alertDescription := "alert-description" alertDescription := "alert-description"
scenarios := []struct { scenarios := []struct {
@ -220,13 +222,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
ExpectedBody string ExpectedBody string
}{ }{
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: true, Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed", ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
}, },
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: false, Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom", ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
@ -234,7 +236,8 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{}, &endpoint.Result{},
@ -252,15 +255,17 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
} }
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) { func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
},
} }
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" { if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true)) t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
} }
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" { if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false)) t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
} }
} }
@ -272,3 +277,119 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be 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{URL: "http://example.com", Body: "default-body"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Body: "default-body"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Headers: map[string]string{"Cache": "true"}},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Headers: map[string]string{"Cache": "true"}},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Body: "group-body"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://group-example.com", Body: "group-body"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "body": "alert-body"}},
ExpectedOutput: Config{URL: "http://alert-example.com", Body: "alert-body"},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{Method: "POST"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"body": "alert-body"}},
ExpectedOutput: Config{URL: "http://example.com", Body: "alert-body", Method: "POST"},
},
}
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.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Body != scenario.ExpectedOutput.Body {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedOutput.Body, got.Body)
}
if got.Headers != nil {
for key, value := range scenario.ExpectedOutput.Headers {
if got.Headers[key] != value {
t.Errorf("expected header %s to be %s, got %s", key, value, got.Headers[key])
}
}
}
// 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

@ -3,6 +3,7 @@ package discord
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,46 +11,73 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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
}
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.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord // AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` 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 { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) 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 { if err != nil {
return err return err
} }
@ -85,7 +113,7 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
var colorCode int var colorCode int
if resolved { if resolved {
@ -110,8 +138,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
title := ":helmet_with_white_cross: Gatus" title := ":helmet_with_white_cross: Gatus"
if provider.Title != "" { if cfg.Title != "" {
title = provider.Title title = cfg.Title
} }
body := Body{ body := Body{
Content: "", Content: "",
@ -134,19 +162,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,50 +11,52 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
WebhookURL: "http://example.com", WebhookURL: "http://example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -74,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -84,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -94,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -104,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -114,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-with-modified-title", Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -122,6 +124,16 @@ func TestAlertProvider_Send(t *testing.T) {
}), }),
ExpectedError: false, ExpectedError: false,
}, },
{
Name: "triggered-with-webhook-override",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"webhook-url": "http://example01.com"}},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@ -175,7 +187,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "triggered-with-modified-title", Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}", ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
@ -183,7 +195,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-no-conditions", Name: "triggered-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}", ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
@ -200,6 +212,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", 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 _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput) 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)
} }
}) })
} }

View File

@ -2,6 +2,7 @@ package email
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"math" "math"
"strings" "strings"
@ -10,10 +11,17 @@ import (
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2" gomail "gopkg.in/mail.v2"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using SMTP var (
type AlertProvider struct { ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidPort = errors.New("port must be between 1 and 65535 inclusively")
ErrMissingHost = errors.New("host is required")
)
type Config struct {
From string `yaml:"from"` From string `yaml:"from"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
@ -23,6 +31,48 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if cfg.Port < 1 || cfg.Port > math.MaxUint16 {
return ErrInvalidPort
}
if len(cfg.Host) == 0 {
return ErrMissingHost
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.Username) > 0 {
cfg.Username = override.Username
}
if len(override.Password) > 0 {
cfg.Password = override.Password
}
if len(override.Host) > 0 {
cfg.Host = override.Host
}
if override.Port > 0 {
cfg.Port = override.Port
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using SMTP
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -34,53 +84,56 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
To string `yaml:"to"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return provider.DefaultConfig.Validate()
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
var username string var username string
if len(provider.Username) > 0 { if len(cfg.Username) > 0 {
username = provider.Username username = cfg.Username
} else { } else {
username = provider.From username = cfg.From
} }
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", provider.From) m.SetHeader("From", cfg.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...) m.SetHeader("To", strings.Split(cfg.To, ",")...)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) m.SetBody("text/plain", body)
var d *gomail.Dialer var d *gomail.Dialer
if len(provider.Password) == 0 { if len(cfg.Password) == 0 {
// Get the domain in the From address // Get the domain in the From address
localName := "localhost" localName := "localhost"
fromParts := strings.Split(provider.From, `@`) fromParts := strings.Split(cfg.From, `@`)
if len(fromParts) == 2 { if len(fromParts) == 2 {
localName = fromParts[1] localName = fromParts[1]
} }
// Create a dialer with no authentication // Create a dialer with no authentication
d = &gomail.Dialer{Host: provider.Host, Port: provider.Port, LocalName: localName} d = &gomail.Dialer{Host: cfg.Host, Port: cfg.Port, LocalName: localName}
} else { } else {
// Create an authenticated dialer // Create an authenticated dialer
d = gomail.NewDialer(provider.Host, provider.Port, username, provider.Password) d = gomail.NewDialer(cfg.Host, cfg.Port, username, cfg.Password)
} }
if provider.ClientConfig != nil && provider.ClientConfig.Insecure { if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true} d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
} }
return d.DialAndSend(m) return d.DialAndSend(m)
@ -116,19 +169,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
return subject, message + description + formattedConditionResults return subject, message + description + formattedConditionResults
} }
// getToForGroup returns the appropriate email integration to for a given group
func (provider *AlertProvider) getToForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
}
}
return provider.To
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -7,61 +7,63 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) { func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "", Config: Config{To: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
From: "from@example.com", From: "from@example.com",
Password: "password", Password: "password",
Host: "smtp.gmail.com", Host: "smtp.gmail.com",
Port: 587, Port: 587,
To: "to@example.com", To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -126,64 +128,104 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "to01@example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "group-to@example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to01@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "group-to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert-to@example.com", "host": "smtp.example.com", "port": 588, "password": "hunter2"}},
ExpectedOutput: Config{From: "from@example.com", To: "alert-to@example.com", Host: "smtp.example.com", Port: 588, Password: "hunter2"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
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 be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
if got.Host != scenario.ExpectedOutput.Host {
t.Errorf("expected host to be %s, got %s", scenario.ExpectedOutput.Host, got.Host)
}
if got.Port != scenario.ExpectedOutput.Port {
t.Errorf("expected port to be %d, got %d", scenario.ExpectedOutput.Port, got.Port)
}
if got.Password != scenario.ExpectedOutput.Password {
t.Errorf("expected password to be %s, got %s", scenario.ExpectedOutput.Password, got.Password)
}
// 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

@ -2,6 +2,7 @@ package gitea
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -11,55 +12,56 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord var (
type AlertProvider struct { ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Assignees is a list of users to assign the issue to
Assignees []string `yaml:"assignees,omitempty"`
username string username string
repositoryOwner string repositoryOwner string
repositoryName string repositoryName string
giteaClient *gitea.Client giteaClient *gitea.Client
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid func (cfg *Config) Validate() error {
func (provider *AlertProvider) IsValid() bool { if len(cfg.RepositoryURL) == 0 {
if provider.ClientConfig == nil { return ErrRepositoryURLNotSet
provider.ClientConfig = client.GetDefaultConfig()
} }
if len(cfg.Token) == 0 {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 { return ErrTokenNotSet
return false
} }
// Validate format of the repository URL // Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL) repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil { if err != nil {
return false return err
} }
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/") pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 { if len(pathParts) != 3 {
return false return ErrInvalidRepositoryURL
} }
provider.repositoryOwner = pathParts[1] if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
provider.repositoryName = pathParts[2] // Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
opts := []gitea.ClientOption{ opts := []gitea.ClientOption{
gitea.SetToken(provider.Token), gitea.SetToken(cfg.Token),
} }
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
// add new http client for skip verify // add new http client for skip verify
httpClient := &http.Client{ httpClient := &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
@ -68,34 +70,62 @@ func (provider *AlertProvider) IsValid() bool {
} }
opts = append(opts, gitea.SetHTTPClient(httpClient)) opts = append(opts, gitea.SetHTTPClient(httpClient))
} }
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil { if err != nil {
return false return err
}
user, _, err := cfg.giteaClient.GetMyUserInfo()
if err != nil {
return err
}
cfg.username = user.UserName
return nil
} }
user, _, err := provider.giteaClient.GetMyUserInfo() func (cfg *Config) Merge(override *Config) {
if err != nil { if override.ClientConfig != nil {
return false cfg.ClientConfig = override.ClientConfig
}
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
} }
provider.username = user.UserName // AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
return true // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
title := "alert(gatus): " + ep.DisplayName() title := "alert(gatus): " + ep.DisplayName()
if !resolved { if !resolved {
_, _, err := provider.giteaClient.CreateIssue( _, _, err = cfg.giteaClient.CreateIssue(
provider.repositoryOwner, cfg.repositoryOwner,
provider.repositoryName, cfg.repositoryName,
gitea.CreateIssueOption{ gitea.CreateIssueOption{
Title: title, Title: title,
Body: provider.buildIssueBody(ep, alert, result), Body: provider.buildIssueBody(ep, alert, result),
Assignees: provider.Assignees, Assignees: cfg.Assignees,
}, },
) )
if err != nil { if err != nil {
@ -103,13 +133,12 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
} }
return nil return nil
} }
issues, _, err := cfg.giteaClient.ListRepoIssues(
issues, _, err := provider.giteaClient.ListRepoIssues( cfg.repositoryOwner,
provider.repositoryOwner, cfg.repositoryName,
provider.repositoryName,
gitea.ListIssueOption{ gitea.ListIssueOption{
State: gitea.StateOpen, State: gitea.StateOpen,
CreatedBy: provider.username, CreatedBy: cfg.username,
ListOptions: gitea.ListOptions{ ListOptions: gitea.ListOptions{
Page: 100, Page: 100,
}, },
@ -118,13 +147,12 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil { if err != nil {
return fmt.Errorf("failed to list issues: %w", err) return fmt.Errorf("failed to list issues: %w", err)
} }
for _, issue := range issues { for _, issue := range issues {
if issue.Title == title { if issue.Title == title {
stateClosed := gitea.StateClosed stateClosed := gitea.StateClosed
_, _, err = provider.giteaClient.EditIssue( _, _, err = cfg.giteaClient.EditIssue(
provider.repositoryOwner, cfg.repositoryOwner,
provider.repositoryName, cfg.repositoryName,
issue.ID, issue.ID,
gitea.EditIssueOption{ gitea.EditIssueOption{
State: &stateClosed, State: &stateClosed,
@ -165,3 +193,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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 (we're returning the cfg here even if there's an error mostly for testing purposes)
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

@ -12,42 +12,46 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-token", Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-repository-name", Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "enterprise-client", Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: false,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.giteaClient, _ = gitea.NewClient("https://gitea.com") cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -167,3 +175,55 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://gitea.com/TwiN/alert-test", "token": "54321", "assignees": []string{"TwiN"}}},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/alert-test", Token: "54321", Assignees: []string{"TwiN"}},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected %d assignees, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
for i, assignee := range got.Assignees {
if assignee != scenario.ExpectedOutput.Assignees[i] {
t.Errorf("expected assignee %s, got %s", scenario.ExpectedOutput.Assignees[i], assignee)
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@ -2,6 +2,7 @@ package github
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strings" "strings"
@ -11,69 +12,104 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord var (
type AlertProvider struct { ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
username string username string
repositoryOwner string repositoryOwner string
repositoryName string repositoryName string
githubClient *github.Client githubClient *github.Client
} }
// IsValid returns whether the provider's configuration is valid func (cfg *Config) Validate() error {
func (provider *AlertProvider) IsValid() bool { if len(cfg.RepositoryURL) == 0 {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 { return ErrRepositoryURLNotSet
return false }
if len(cfg.Token) == 0 {
return ErrTokenNotSet
} }
// Validate format of the repository URL // Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL) repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil { if err != nil {
return false return err
} }
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/") pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 { if len(pathParts) != 3 {
return false return ErrInvalidRepositoryURL
} }
provider.repositoryOwner = pathParts[1] if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
provider.repositoryName = pathParts[2] // Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
// Create oauth2 HTTP client with GitHub token // Create oauth2 HTTP client with GitHub token
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{ httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: provider.Token, AccessToken: cfg.Token,
})) }))
// Create GitHub client // Create GitHub client
if baseURL == "https://github.com" { if baseURL == "https://github.com" {
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource) cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else { } else {
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource) cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
if err != nil { if err != nil {
return false return fmt.Errorf("failed to create enterprise GitHub client: %w", err)
} }
} }
// Retrieve the username once to validate that the token is valid // Retrieve the username once to validate that the token is valid
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
user, _, err := provider.githubClient.Users.Get(ctx, "") user, _, err := cfg.githubClient.Users.Get(ctx, "")
if err != nil { if err != nil {
return false return fmt.Errorf("failed to retrieve GitHub user: %w", err)
} }
provider.username = *user.Login cfg.username = *user.Login
return true return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
title := "alert(gatus): " + ep.DisplayName() title := "alert(gatus): " + ep.DisplayName()
if !resolved { if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{ _, _, err := cfg.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{
Title: github.String(title), Title: github.String(title),
Body: github.String(provider.buildIssueBody(ep, alert, result)), Body: github.String(provider.buildIssueBody(ep, alert, result)),
}) })
@ -81,9 +117,9 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return fmt.Errorf("failed to create issue: %w", err) return fmt.Errorf("failed to create issue: %w", err)
} }
} else { } else {
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{ issues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{
State: "open", State: "open",
Creator: provider.username, Creator: cfg.username,
ListOptions: github.ListOptions{PerPage: 100}, ListOptions: github.ListOptions{PerPage: 100},
}) })
if err != nil { if err != nil {
@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
} }
for _, issue := range issues { for _, issue := range issues {
if *issue.Title == title { if *issue.Title == title {
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{ _, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{
State: github.String("closed"), State: github.String("closed"),
}) })
if err != nil { if err != nil {
@ -130,3 +166,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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 (we're returning the cfg here even if there's an error mostly for testing purposes)
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

@ -12,42 +12,46 @@ import (
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-token", Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-repository-name", Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "enterprise-client", Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.githubClient = github.NewClient(nil) cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
cfg.githubClient = github.NewClient(nil)
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -167,3 +175,47 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "token": "54321"}},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/alert-test", Token: "54321"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@ -13,55 +13,97 @@ import (
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/uuid" "github.com/google/uuid"
"gopkg.in/yaml.v3"
) )
const (
DefaultSeverity = "critical"
DefaultMonitoringTool = "gatus"
)
var (
ErrInvalidWebhookURL = fmt.Errorf("invalid webhook-url")
ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
Severity string `yaml:"severity,omitempty"` // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
MonitoringTool string `yaml:"monitoring-tool,omitempty"` // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
EnvironmentName string `yaml:"environment-name,omitempty"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrInvalidWebhookURL
} else if _, err := url.Parse(cfg.WebhookURL); err != nil {
return ErrInvalidWebhookURL
}
if len(cfg.AuthorizationKey) == 0 {
return ErrAuthorizationKeyNotSet
}
if len(cfg.Severity) == 0 {
cfg.Severity = DefaultSeverity
}
if len(cfg.MonitoringTool) == 0 {
cfg.MonitoringTool = DefaultMonitoringTool
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.AuthorizationKey) > 0 {
cfg.AuthorizationKey = override.AuthorizationKey
}
if len(override.Severity) > 0 {
cfg.Severity = override.Severity
}
if len(override.MonitoringTool) > 0 {
cfg.MonitoringTool = override.MonitoringTool
}
if len(override.EnvironmentName) > 0 {
cfg.EnvironmentName = override.EnvironmentName
}
if len(override.Service) > 0 {
cfg.Service = override.Service
}
}
// AlertProvider is the configuration necessary for sending an alert using GitLab // AlertProvider is the configuration necessary for sending an alert using GitLab
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab DefaultConfig Config `yaml:",inline"`
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
Severity string `yaml:"severity,omitempty"`
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus
MonitoringTool string `yaml:"monitoring-tool,omitempty"`
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
EnvironmentName string `yaml:"environment-name,omitempty"`
// Service affected. Defaults to endpoint display name
Service string `yaml:"service,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 { return provider.DefaultConfig.Validate()
return false
}
// Validate format of the repository URL
_, err := url.Parse(provider.WebhookURL)
if err != nil {
return false
}
return true
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
if len(alert.ResolveKey) == 0 { if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString() alert.ResolveKey = uuid.NewString()
} }
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey)) request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@ -87,30 +129,20 @@ type AlertBody struct {
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard. GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
} }
func (provider *AlertProvider) monitoringTool() string {
if len(provider.MonitoringTool) > 0 {
return provider.MonitoringTool
}
return "gatus"
}
func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
if len(provider.Service) > 0 {
return provider.Service
}
return ep.DisplayName()
}
// buildAlertBody builds the body of the alert // buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildAlertBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
service := cfg.Service
if len(service) == 0 {
service = ep.DisplayName()
}
body := AlertBody{ body := AlertBody{
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)), Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
StartTime: result.Timestamp.Format(time.RFC3339), StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(ep), Service: service,
MonitoringTool: provider.monitoringTool(), MonitoringTool: cfg.MonitoringTool,
Hosts: ep.URL, Hosts: ep.URL,
GitlabEnvironmentName: provider.EnvironmentName, GitlabEnvironmentName: cfg.EnvironmentName,
Severity: provider.Severity, Severity: cfg.Severity,
Fingerprint: alert.ResolveKey, Fingerprint: alert.ResolveKey,
} }
if resolved { if resolved {
@ -148,3 +180,25 @@ func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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 (we're returning the cfg here even if there's an error mostly for testing purposes)
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

@ -11,37 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-webhook-url", Name: "missing-webhook-url",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-authorization-key", Name: "missing-authorization-key",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@ -61,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: false, ExpectedError: false,
@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: false, ExpectedError: false,
@ -116,21 +120,26 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
{ {
Name: "triggered", Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
}, },
{ {
Name: "no-description", Name: "no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildAlertBody( body := scenario.Provider.buildAlertBody(
cfg,
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -156,3 +165,59 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345", Severity: DefaultSeverity, MonitoringTool: DefaultMonitoringTool},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "authorization-key": "54321", "severity": "info", "monitoring-tool": "not-gatus", "environment-name": "prod", "service": "example"}},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "54321", Severity: "info", MonitoringTool: "not-gatus", EnvironmentName: "prod", Service: "example"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
if got.AuthorizationKey != scenario.ExpectedOutput.AuthorizationKey {
t.Errorf("expected AuthorizationKey %s, got %s", scenario.ExpectedOutput.AuthorizationKey, got.AuthorizationKey)
}
if got.Severity != scenario.ExpectedOutput.Severity {
t.Errorf("expected Severity %s, got %s", scenario.ExpectedOutput.Severity, got.Severity)
}
if got.MonitoringTool != scenario.ExpectedOutput.MonitoringTool {
t.Errorf("expected MonitoringTool %s, got %s", scenario.ExpectedOutput.MonitoringTool, got.MonitoringTool)
}
if got.EnvironmentName != scenario.ExpectedOutput.EnvironmentName {
t.Errorf("expected EnvironmentName %s, got %s", scenario.ExpectedOutput.EnvironmentName, got.EnvironmentName)
}
if got.Service != scenario.ExpectedOutput.Service {
t.Errorf("expected Service %s, got %s", scenario.ExpectedOutput.Service, got.Service)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@ -3,6 +3,7 @@ package googlechat
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,14 +11,38 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"`
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
}
}
// AlertProvider is the configuration necessary for sending an alert using Google chat // AlertProvider is the configuration necessary for sending an alert using Google chat
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -29,35 +54,36 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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(ep, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@ -185,19 +211,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -213,64 +213,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", 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 _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) 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)
} }
}) })
} }

View File

@ -3,6 +3,7 @@ package gotify
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,40 +11,72 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const DefaultPriority = 5 const DefaultPriority = 5
var (
ErrServerURLNotSet = errors.New("server URL not set")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
ServerURL string `yaml:"server-url"` // URL of the Gotify server
Token string `yaml:"token"` // Token to use when sending a message to the Gotify server
Priority int `yaml:"priority,omitempty"` // Priority of the message. Defaults to DefaultPriority.
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.ServerURL) == 0 {
return ErrServerURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Gotify // AlertProvider is the configuration necessary for sending an alert using Gotify
type AlertProvider struct { type AlertProvider struct {
// ServerURL is the URL of the Gotify server DefaultConfig Config `yaml:",inline"`
ServerURL string `yaml:"server-url"`
// Token is the token to use when sending a message to the Gotify server
Token string `yaml:"token"`
// Priority is the priority of the message
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.Priority == 0 { return provider.DefaultConfig.Validate()
provider.Priority = DefaultPriority
}
return len(provider.ServerURL) > 0 && len(provider.Token) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, buffer)
if err != nil { if err != nil {
return err return err
} }
@ -67,7 +100,7 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { 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) message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@ -89,13 +122,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
} }
message += formattedConditionResults message += formattedConditionResults
title := "Gatus: " + ep.DisplayName() title := "Gatus: " + ep.DisplayName()
if provider.Title != "" { if cfg.Title != "" {
title = provider.Title title = cfg.Title
} }
bodyAsJSON, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
Message: message, Message: message,
Title: title, Title: title,
Priority: provider.Priority, Priority: cfg.Priority,
}) })
return bodyAsJSON return bodyAsJSON
} }
@ -104,3 +137,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
provider AlertProvider provider AlertProvider
@ -17,29 +17,29 @@ func TestAlertProvider_IsValid(t *testing.T) {
}{ }{
{ {
name: "valid", name: "valid",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true, expected: true,
}, },
{ {
name: "invalid-server-url", name: "invalid-server-url",
provider: AlertProvider{ServerURL: "", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
expected: false, expected: false,
}, },
{ {
name: "invalid-app-token", name: "invalid-app-token",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
expected: false, expected: false,
}, },
{ {
name: "no-priority-should-use-default-value", name: "no-priority-should-use-default-value",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true, expected: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected { if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid()) t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
} }
}) })
} }
@ -60,21 +60,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "custom-title", Name: "custom-title",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
@ -83,6 +83,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: endpointName}, &endpoint.Endpoint{Name: endpointName},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -103,3 +104,60 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
provider := AlertProvider{DefaultAlert: &alert.Alert{}}
if provider.GetDefaultAlert() != provider.DefaultAlert {
t.Error("expected default alert to be returned")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{ServerURL: "https://gotify.example.com", Token: "12345", Priority: DefaultPriority},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://gotify.group-example.com", "token": "54321", "title": "alert-title", "priority": 3}},
ExpectedOutput: Config{ServerURL: "https://gotify.group-example.com", Token: "54321", Title: "alert-title", Priority: 3},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected server URL to be %s, got %s", scenario.ExpectedOutput.ServerURL, got.ServerURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Title != scenario.ExpectedOutput.Title {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedOutput.Title, got.Title)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority to be %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@ -3,6 +3,7 @@ package jetbrainsspace
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,13 +11,50 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrProjectNotSet = errors.New("project not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Project string `yaml:"project"` // Project name
ChannelID string `yaml:"channel-id"` // Chat Channel ID
Token string `yaml:"token"` // Bearer Token
}
func (cfg *Config) Validate() error {
if len(cfg.Project) == 0 {
return ErrProjectNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Project) > 0 {
cfg.Project = override.Project
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space // AlertProvider is the configuration necessary for sending an alert using JetBrains Space
type AlertProvider struct { type AlertProvider struct {
Project string `yaml:"project"` // JetBrains Space Project name DefaultConfig Config `yaml:",inline"`
ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
Token string `yaml:"token"` // JetBrains Space Bearer Token
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -28,33 +66,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
ChannelID string `yaml:"channel-id"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.Project) > 0 && len(provider.ChannelID) > 0 && len(provider.Token) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) cfg, err := provider.GetConfig(ep.Group, alert)
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
request, err := http.NewRequest(http.MethodPost, url, buffer) request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+provider.Token) request.Header.Set("Authorization", "Bearer "+cfg.Token)
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@ -103,9 +145,9 @@ type Icon struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{ body := Body{
Channel: "id:" + provider.getChannelIDForGroup(ep.Group), Channel: "id:" + cfg.ChannelID,
Content: Content{ Content: Content{
ClassName: "ChatMessage.Block", ClassName: "ChatMessage.Block",
Sections: []Section{{ Sections: []Section{{
@ -144,19 +186,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON return bodyAsJSON
} }
// getChannelIDForGroup returns the appropriate channel ID to for a given group override
func (provider *AlertProvider) getChannelIDForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.ChannelID
}
}
}
return provider.ChannelID
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,54 +11,56 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{Project: ""} invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"} validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Project: "foobar", DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "http://example.com", Config: Config{ChannelID: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Project: "foobar", DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "", Config: Config{ChannelID: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
Project: "foo", Project: "foo",
ChannelID: "bar", ChannelID: "bar",
Token: "baz", Token: "baz",
},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "foobar", Config: Config{ChannelID: "foobar"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -77,7 +79,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -87,7 +89,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -97,7 +99,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -107,7 +109,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -153,40 +155,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
}, },
{ {
Name: "triggered-with-group", Name: "triggered-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
}, },
{ {
Name: "resolved-with-group", Name: "resolved-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -217,62 +220,98 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getChannelIDForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ChannelID: "foobar", Config: Config{ChannelID: "group-channel"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ChannelID: "foobar", Config: Config{ChannelID: "group-channel"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "foobar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
if got.Project != scenario.ExpectedOutput.Project {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
// 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

@ -3,6 +3,7 @@ package matrix
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
@ -13,11 +14,56 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const defaultServerURL = "https://matrix-client.matrix.org"
var (
ErrAccessTokenNotSet = errors.New("access-token not set")
ErrInternalRoomID = errors.New("internal-room-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
// ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"`
// AccessToken is the bot user's access token to send messages
AccessToken string `yaml:"access-token"`
// InternalRoomID is the room that the bot user has permissions to send messages to
InternalRoomID string `yaml:"internal-room-id"`
}
func (cfg *Config) Validate() error {
if len(cfg.ServerURL) == 0 {
cfg.ServerURL = defaultServerURL
}
if len(cfg.AccessToken) == 0 {
return ErrAccessTokenNotSet
}
if len(cfg.InternalRoomID) == 0 {
return ErrInternalRoomID
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.AccessToken) > 0 {
cfg.AccessToken = override.AccessToken
}
if len(override.InternalRoomID) > 0 {
cfg.InternalRoomID = override.InternalRoomID
}
}
// AlertProvider is the configuration necessary for sending an alert using Matrix // AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct { type AlertProvider struct {
ProviderConfig `yaml:",inline"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -29,53 +75,39 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
Config `yaml:",inline"`
ProviderConfig `yaml:",inline"`
} }
const defaultServerURL = "https://matrix-client.matrix.org" // Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
type ProviderConfig struct {
// ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"`
// AccessToken is the bot user's access token to send messages
AccessToken string `yaml:"access-token"`
// InternalRoomID is the room that the bot user has permissions to send messages to
InternalRoomID string `yaml:"internal-room-id"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) cfg, err := provider.GetConfig(ep.Group, alert)
config := provider.getConfigForGroup(ep.Group) if err != nil {
if config.ServerURL == "" { return err
config.ServerURL = defaultServerURL
} }
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
// The Matrix endpoint requires a unique transaction ID for each event sent // The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24) txnId := randStringBytes(24)
request, err := http.NewRequest( request, err := http.NewRequest(
http.MethodPut, http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s", fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
config.ServerURL, cfg.ServerURL,
url.PathEscape(config.InternalRoomID), url.PathEscape(cfg.InternalRoomID),
txnId, txnId,
url.QueryEscape(config.AccessToken), url.QueryEscape(cfg.AccessToken),
), ),
buffer, buffer,
) )
@ -167,18 +199,6 @@ func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *end
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults) return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
} }
// getConfigForGroup returns the appropriate configuration for a given group
func (provider *AlertProvider) getConfigForGroup(group string) ProviderConfig {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.ProviderConfig
}
}
}
return provider.ProviderConfig
}
func randStringBytes(n int) string { func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID // All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@ -194,3 +214,34 @@ func randStringBytes(n int) string {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,75 +11,75 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{ invalidProvider := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
} }
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
validProviderWithHomeserver := AlertProvider{ validProviderWithHomeserver := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
} }
if !validProviderWithHomeserver.IsValid() { if err := validProviderWithHomeserver.Validate(); err != nil {
t.Error("provider with homeserver should've been valid") t.Error("provider with homeserver should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Group: "", Group: "",
ProviderConfig: ProviderConfig{ Config: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -87,7 +87,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
ExpectedError bool ExpectedError bool
}{ }{
{ {
Name: "triggered", Name: "triggered-with-bad-config",
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}), }),
ExpectedError: true,
},
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a: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, ExpectedError: false,
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -227,17 +237,18 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getConfigForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput ProviderConfig InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example01.com", ServerURL: "https://group-example.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:group-example.com",
}, },
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example01.com", ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
ServerURL: "https://group-example.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:example01.com",
}, },
@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
ServerURL: "https://example01.com", ExpectedOutput: Config{
AccessToken: "12", ServerURL: "https://alert-example.com",
InternalRoomID: "!a:example01.com", AccessToken: "123",
InternalRoomID: "!a:alert-example.com",
}, },
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput { outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Errorf("expected no error, got %v", err)
}
if outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected ServerURL to be %s, got %s", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)
}
if outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {
t.Errorf("expected AccessToken to be %s, got %s", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)
}
if outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {
t.Errorf("expected InternalRoomID to be %s, got %s", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)
}
// 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

@ -3,6 +3,7 @@ package mattermost
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,17 +11,42 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"`
Channel string `yaml:"channel,omitempty"`
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.Channel) > 0 {
cfg.Channel = override.Channel
}
}
// AlertProvider is the configuration necessary for sending an alert using Mattermost // AlertProvider is the configuration necessary for sending an alert using Mattermost
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// Channel is the optional setting to override the default webhook's channel
Channel string `yaml:"channel,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -32,35 +58,36 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
if provider.Overrides != nil { if provider.Overrides != nil {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved))) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@ -96,7 +123,7 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { 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) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@ -122,7 +149,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
body := Body{ body := Body{
Channel: provider.Channel, Channel: cfg.Channel,
Text: "", Text: "",
Username: "gatus", Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
@ -147,19 +174,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,54 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideWebHookUrl := AlertProvider{ providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Config: Config{WebhookURL: ""},
WebhookURL: "",
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideWebHookUrl.IsValid() { if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
t.Error("provider WebHookURL shouldn't have been valid") t.Error("provider WebHookURL shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -168,6 +164,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -198,64 +195,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", 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 _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) 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)
} }
}) })
} }

View File

@ -3,6 +3,7 @@ package messagebird
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,37 +11,75 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const restAPIURL = "https://rest.messagebird.com/messages"
restAPIURL = "https://rest.messagebird.com/messages"
var (
ErrorAccessKeyNotSet = errors.New("access-key not set")
ErrorOriginatorNotSet = errors.New("originator not set")
ErrorRecipientsNotSet = errors.New("recipients not set")
) )
// AlertProvider is the configuration necessary for sending an alert using Messagebird type Config struct {
type AlertProvider struct {
AccessKey string `yaml:"access-key"` AccessKey string `yaml:"access-key"`
Originator string `yaml:"originator"` Originator string `yaml:"originator"`
Recipients string `yaml:"recipients"` Recipients string `yaml:"recipients"`
}
func (cfg *Config) Validate() error {
if len(cfg.AccessKey) == 0 {
return ErrorAccessKeyNotSet
}
if len(cfg.Originator) == 0 {
return ErrorOriginatorNotSet
}
if len(cfg.Recipients) == 0 {
return ErrorRecipientsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKey) > 0 {
cfg.AccessKey = override.AccessKey
}
if len(override.Originator) > 0 {
cfg.Originator = override.Originator
}
if len(override.Recipients) > 0 {
cfg.Recipients = override.Recipients
}
}
// AlertProvider is the configuration necessary for sending an alert using Messagebird
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms // Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) 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, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey)) request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", cfg.AccessKey))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@ -60,7 +99,7 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@ -68,8 +107,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Originator: provider.Originator, Originator: cfg.Originator,
Recipients: provider.Recipients, Recipients: cfg.Recipients,
Body: message, Body: message,
}) })
return body return body
@ -79,3 +118,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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

@ -13,15 +13,17 @@ import (
func TestMessagebirdAlertProvider_IsValid(t *testing.T) { func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
DefaultConfig: Config{
AccessKey: "1", AccessKey: "1",
Originator: "1", Originator: "1",
Recipients: "1", Recipients: "1",
},
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}", ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "4", Originator: "5", Recipients: "6"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}", ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
@ -131,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil { if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
}) })
@ -160,3 +163,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"access-key": "4", "originator": "5", "recipients": "6"}},
ExpectedOutput: Config{AccessKey: "4", Originator: "5", Recipients: "6"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.AccessKey != scenario.ExpectedOutput.AccessKey {
t.Errorf("expected access key to be %s, got %s", scenario.ExpectedOutput.AccessKey, got.AccessKey)
}
if got.Originator != scenario.ExpectedOutput.Originator {
t.Errorf("expected originator to be %s, got %s", scenario.ExpectedOutput.Originator, got.Originator)
}
if got.Recipients != scenario.ExpectedOutput.Recipients {
t.Errorf("expected recipients to be %s, got %s", scenario.ExpectedOutput.Recipients, got.Recipients)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@ -3,6 +3,7 @@ package ntfy
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -12,6 +13,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
@ -20,8 +22,14 @@ const (
TokenPrefix = "tk_" TokenPrefix = "tk_"
) )
// AlertProvider is the configuration necessary for sending an alert using Slack var (
type AlertProvider struct { ErrInvalidToken = errors.New("invalid token")
ErrTopicNotSet = errors.New("topic not set")
ErrInvalidPriority = errors.New("priority must between 1 and 5 inclusively")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Topic string `yaml:"topic"` Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
@ -30,6 +38,57 @@ type AlertProvider struct {
Click string `yaml:"click,omitempty"` // Defaults to "" Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
cfg.URL = DefaultURL
}
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.Token) > 0 && !strings.HasPrefix(cfg.Token, TokenPrefix) {
return ErrInvalidToken
}
if len(cfg.Topic) == 0 {
return ErrTopicNotSet
}
if cfg.Priority < 1 || cfg.Priority > 5 {
return ErrInvalidPriority
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Topic) > 0 {
cfg.Topic = override.Topic
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if override.Priority > 0 {
cfg.Priority = override.Priority
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Email) > 0 {
cfg.Email = override.Email
}
if len(override.Click) > 0 {
cfg.Click = override.Click
}
if override.DisableFirebase {
cfg.DisableFirebase = true
}
if override.DisableCache {
cfg.DisableCache = true
}
}
// AlertProvider is the configuration necessary for sending an alert using Slack
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -41,65 +100,53 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
Topic string `yaml:"topic"` Config `yaml:",inline"`
URL string `yaml:"url"`
Priority int `yaml:"priority"`
Token string `yaml:"token"`
Email string `yaml:"email"`
Click string `yaml:"click"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if len(provider.URL) == 0 {
provider.URL = DefaultURL
}
if provider.Priority == 0 {
provider.Priority = DefaultPriority
}
isTokenValid := true
if len(provider.Token) > 0 {
isTokenValid = strings.HasPrefix(provider.Token, TokenPrefix)
}
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if len(override.Group) == 0 { if len(override.Group) == 0 {
return false return ErrDuplicateGroupOverride
} }
if _, ok := registeredGroups[override.Group]; ok { if _, ok := registeredGroups[override.Group]; ok {
return false return ErrDuplicateGroupOverride
} }
if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) { if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {
return false return ErrDuplicateGroupOverride
} }
if override.Priority < 0 || override.Priority >= 6 { if override.Priority < 0 || override.Priority >= 6 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
override := provider.getGroupOverride(ep.Group) cfg, err := provider.GetConfig(ep.Group, alert)
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved, override)) if err != nil {
url := provider.getURL(override) return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := cfg.URL
request, err := http.NewRequest(http.MethodPost, url, buffer) request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
if token := provider.getToken(override); len(token) > 0 { if token := cfg.Token; len(token) > 0 {
request.Header.Set("Authorization", "Bearer "+token) request.Header.Set("Authorization", "Bearer "+token)
} }
if provider.DisableFirebase { if cfg.DisableFirebase {
request.Header.Set("Firebase", "no") request.Header.Set("Firebase", "no")
} }
if provider.DisableCache { if cfg.DisableCache {
request.Header.Set("Cache", "no") request.Header.Set("Cache", "no")
} }
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
@ -125,7 +172,7 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, override *Override) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, tag string var message, formattedConditionResults, tag string
if resolved { if resolved {
tag = "white_check_mark" tag = "white_check_mark"
@ -148,13 +195,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
} }
message += formattedConditionResults message += formattedConditionResults
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Topic: provider.getTopic(override), Topic: cfg.Topic,
Title: "Gatus: " + ep.DisplayName(), Title: "Gatus: " + ep.DisplayName(),
Message: message, Message: message,
Tags: []string{tag}, Tags: []string{tag},
Priority: provider.getPriority(override), Priority: cfg.Priority,
Email: provider.getEmail(override), Email: cfg.Email,
Click: provider.getClick(override), Click: cfg.Click,
}) })
return body return body
} }
@ -164,55 +211,33 @@ func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
func (provider *AlertProvider) getGroupOverride(group string) *Override { // 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 { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if group == override.Group { if group == override.Group {
return &override cfg.Merge(&override.Config)
break
} }
} }
} }
return nil // 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
} }
func (provider *AlertProvider) getTopic(override *Override) string { // ValidateOverrides validates the alert's provider override and, if present, the group override
if override != nil && len(override.Topic) > 0 { func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
return override.Topic _, err := provider.GetConfig(group, alert)
} return err
return provider.Topic
}
func (provider *AlertProvider) getURL(override *Override) string {
if override != nil && len(override.URL) > 0 {
return override.URL
}
return provider.URL
}
func (provider *AlertProvider) getPriority(override *Override) int {
if override != nil && override.Priority > 0 {
return override.Priority
}
return provider.Priority
}
func (provider *AlertProvider) getToken(override *Override) string {
if override != nil && len(override.Token) > 0 {
return override.Token
}
return provider.Token
}
func (provider *AlertProvider) getEmail(override *Override) string {
if override != nil && len(override.Email) > 0 {
return override.Email
}
return provider.Email
}
func (provider *AlertProvider) getClick(override *Override) string {
if override != nil && len(override.Click) > 0 {
return override.Click
}
return provider.Click
} }

View File

@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
provider AlertProvider provider AlertProvider
@ -19,74 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
}{ }{
{ {
name: "valid", name: "valid",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
expected: true, expected: true,
}, },
{ {
name: "no-url-should-use-default-value", name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
expected: true, expected: true,
}, },
{ {
name: "valid-with-token", name: "valid-with-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "tk_faketoken"}},
expected: true, expected: true,
}, },
{ {
name: "invalid-token", name: "invalid-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "xx_faketoken"}},
expected: false, expected: false,
}, },
{ {
name: "invalid-topic", name: "invalid-topic",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "", Priority: 1}},
expected: false, expected: false,
}, },
{ {
name: "invalid-priority-too-high", name: "invalid-priority-too-high",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 6}},
expected: false, expected: false,
}, },
{ {
name: "invalid-priority-too-low", name: "invalid-priority-too-low",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: -1}},
expected: false, expected: false,
}, },
{ {
name: "no-priority-should-use-default-value", name: "no-priority-should-use-default-value",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example"}},
expected: true, expected: true,
}, },
{ {
name: "invalid-override-token", name: "invalid-override-token",
provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g", Token: "xx_faketoken"}}}, provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Token: "xx_faketoken"}}}},
expected: false, expected: false,
}, },
{ {
name: "invalid-override-priority", name: "invalid-override-priority",
provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g", Priority: 8}}}, provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Priority: 8}}}},
expected: false, expected: false,
}, },
{ {
name: "no-override-group-name", name: "no-override-group-name",
provider: AlertProvider{Topic: "example", Overrides: []Override{Override{}}}, provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{}}},
expected: false, expected: false,
}, },
{ {
name: "duplicate-override-group-names", name: "duplicate-override-group-names",
provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g"}, Override{Group: "g"}}}, provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g"}, {Group: "g"}}},
expected: false, expected: false,
}, },
{ {
name: "valid-override", name: "valid-override",
provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g1", Priority: 4, Click: "https://example.com"}, Override{Group: "g2", Topic: "Example", Token: "tk_faketoken"}}}, provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g1", Config: Config{Priority: 4, Click: "https://example.com"}}, {Group: "g2", Config: Config{Topic: "Example", Token: "tk_faketoken"}}}},
expected: true, expected: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected { err := scenario.provider.Validate()
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid()) if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
} }
}) })
} }
@ -100,53 +104,59 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
Override *Override
ExpectedBody string ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Override: nil,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
Override: nil,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
}, },
{ {
Name: "triggered-email", Name: "triggered-email",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Override: nil,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
}, },
{ {
Name: "resolved-email", Name: "resolved-email",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
Override: nil,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`,
}, },
{ {
Name: "override", Name: "group-override",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Override: &Override{Group: "g", Topic: "override-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}, ExpectedBody: `{"topic":"group-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
ExpectedBody: `{"topic":"override-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`, },
{
Name: "alert-override",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"topic": "alert-topic"}},
Resolved: false,
ExpectedBody: `{"topic":"alert-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
cfg,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -156,7 +166,6 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
}, },
scenario.Resolved, scenario.Resolved,
scenario.Override,
) )
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
@ -182,7 +191,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Group: "", Group: "",
@ -193,7 +202,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "token", Name: "token",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Group: "", Group: "",
@ -205,7 +214,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "no firebase", Name: "no firebase",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Group: "", Group: "",
@ -217,7 +226,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "no cache", Name: "no cache",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Group: "", Group: "",
@ -229,7 +238,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "neither firebase & cache", Name: "neither firebase & cache",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Group: "", Group: "",
@ -242,7 +251,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "overrides", Name: "overrides",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken", Overrides: []Override{Override{Group: "other-group", URL: "https://example.com", Token: "tk_othertoken"}, Override{Group: "test-group", Token: "tk_test_token"}}}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, Overrides: []Override{Override{Group: "other-group", Config: Config{URL: "https://example.com", Token: "tk_othertoken"}}, Override{Group: "test-group", Config: Config{Token: "tk_test_token"}}}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Group: "test-group", Group: "test-group",
@ -273,7 +282,7 @@ func TestAlertProvider_Send(t *testing.T) {
// Close the server when test finishes // Close the server when test finishes
defer server.Close() defer server.Close()
scenario.Provider.URL = server.URL scenario.Provider.DefaultConfig.URL = server.URL
err := scenario.Provider.Send( err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group}, &endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
&scenario.Alert, &scenario.Alert,
@ -288,8 +297,118 @@ func TestAlertProvider_Send(t *testing.T) {
if err != nil { if err != nil {
t.Error("Encountered an error on Send: ", err) t.Error("Encountered an error on Send: ", err)
} }
}) })
} }
}
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{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "topic": "alert-topic", "priority": 3}},
ExpectedOutput: Config{URL: "http://alert-example.com", Topic: "alert-topic", Priority: 3},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{Topic: "group-topic"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"priority": 3}},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "group-topic", Priority: 3},
},
}
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.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected url %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Topic != scenario.ExpectedOutput.Topic {
t.Errorf("expected topic %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// 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

@ -3,6 +3,7 @@ package opsgenie
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -12,13 +13,18 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
restAPI = "https://api.opsgenie.com/v2/alerts" restAPI = "https://api.opsgenie.com/v2/alerts"
) )
type AlertProvider struct { var (
ErrAPIKeyNotSet = errors.New("api-key not set")
)
type Config struct {
// APIKey to use for // APIKey to use for
APIKey string `yaml:"api-key"` APIKey string `yaml:"api-key"`
@ -46,26 +52,74 @@ type AlertProvider struct {
// //
// default: [] // default: []
Tags []string `yaml:"tags"` Tags []string `yaml:"tags"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.Source) == 0 {
cfg.Source = "gatus"
}
if len(cfg.EntityPrefix) == 0 {
cfg.EntityPrefix = "gatus-"
}
if len(cfg.AliasPrefix) == 0 {
cfg.AliasPrefix = "gatus-healthcheck-"
}
if len(cfg.Priority) == 0 {
cfg.Priority = "P1"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.Priority) > 0 {
cfg.Priority = override.Priority
}
if len(override.Source) > 0 {
cfg.Source = override.Source
}
if len(override.EntityPrefix) > 0 {
cfg.EntityPrefix = override.EntityPrefix
}
if len(override.AliasPrefix) > 0 {
cfg.AliasPrefix = override.AliasPrefix
}
if len(override.Tags) > 0 {
cfg.Tags = override.Tags
}
}
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.APIKey) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://docs.opsgenie.com/docs/alert-api // Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
err := provider.createAlert(ep, alert, result, resolved) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
err = provider.sendAlertRequest(cfg, ep, alert, result, resolved)
if err != nil { if err != nil {
return err return err
} }
if resolved { if resolved {
err = provider.closeAlert(ep, alert) err = provider.closeAlert(cfg, ep, alert)
if err != nil { if err != nil {
return err return err
} }
@ -75,24 +129,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = "" alert.ResolveKey = ""
} else { } else {
alert.ResolveKey = provider.alias(buildKey(ep)) alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
} }
} }
return nil return nil
} }
func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(ep, alert, result, resolved) payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
return provider.sendRequest(restAPI, http.MethodPost, payload) return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
} }
func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error { func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(ep, alert) payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias" url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
return provider.sendRequest(url, http.MethodPost, payload) return provider.sendRequest(cfg, url, http.MethodPost, payload)
} }
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error { func (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return fmt.Errorf("error build alert with payload %v: %w", payload, err) return fmt.Errorf("error build alert with payload %v: %w", payload, err)
@ -102,7 +156,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+provider.APIKey) request.Header.Set("Authorization", "GenieKey "+cfg.APIKey)
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@ -115,7 +169,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return nil return nil
} }
func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest { func (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description string var message, description string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
@ -158,11 +212,11 @@ func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, ale
return alertCreateRequest{ return alertCreateRequest{
Message: message, Message: message,
Description: description, Description: description,
Source: provider.source(), Source: cfg.Source,
Priority: provider.priority(), Priority: cfg.Priority,
Alias: provider.alias(key), Alias: cfg.AliasPrefix + key,
Entity: provider.entity(key), Entity: cfg.EntityPrefix + key,
Tags: provider.Tags, Tags: cfg.Tags,
Details: details, Details: details,
} }
} }
@ -174,43 +228,33 @@ func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, aler
} }
} }
func (provider *AlertProvider) source() string {
source := provider.Source
if source == "" {
return "gatus"
}
return source
}
func (provider *AlertProvider) alias(key string) string {
alias := provider.AliasPrefix
if alias == "" {
alias = "gatus-healthcheck-"
}
return alias + key
}
func (provider *AlertProvider) entity(key string) string {
alias := provider.EntityPrefix
if alias == "" {
alias = "gatus-"
}
return alias + key
}
func (provider *AlertProvider) priority() string {
priority := provider.Priority
if priority == "" {
return "P1"
}
return priority
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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
}
func buildKey(ep *endpoint.Endpoint) string { func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(ep.Name) name := toKebabCase(ep.Name)
if ep.Group == "" { if ep.Group == "" {

View File

@ -11,13 +11,13 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{APIKey: ""} invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"} validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1}, Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false, Resolved: false,
ExpectedError: false, ExpectedError: false,
@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: false, ExpectedError: false,
@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@ -74,7 +74,6 @@ func TestAlertProvider_Send(t *testing.T) {
}), }),
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
@ -113,7 +112,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}{ }{
{ {
Name: "missing all params (unresolved)", Name: "missing all params (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{}, Result: &endpoint.Result{},
@ -131,7 +130,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}, },
{ {
Name: "missing all params (resolved)", Name: "missing all params (resolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{}, Result: &endpoint.Result{},
@ -149,7 +148,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}, },
{ {
Name: "with default options (unresolved)", Name: "with default options (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
FailureThreshold: 3, FailureThreshold: 3,
@ -184,12 +183,14 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{ {
Name: "with custom options (resolved)", Name: "with custom options (resolved)",
Provider: &AlertProvider{ Provider: &AlertProvider{
DefaultConfig: Config{
Priority: "P5", Priority: "P5",
EntityPrefix: "oompa-", EntityPrefix: "oompa-",
AliasPrefix: "loompa-", AliasPrefix: "loompa-",
Source: "gatus-hc", Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"}, Tags: []string{"do-ba-dee-doo"},
}, },
},
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
SuccessThreshold: 4, SuccessThreshold: 4,
@ -220,7 +221,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{ {
Name: "with default options and details (unresolved)", Name: "with default options and details (unresolved)",
Provider: &AlertProvider{ Provider: &AlertProvider{
Tags: []string{"foo"}, DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
}, },
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
actual := scenario actual := scenario
t.Run(actual.Name, func(t *testing.T) { t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) { _ = scenario.Provider.Validate()
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want) if got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
t.Errorf("got:\n%v\nwant:\n%v", got, actual.want)
} }
}) })
} }
@ -307,7 +309,6 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
}, },
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
actual := scenario actual := scenario
t.Run(actual.Name, func(t *testing.T) { t.Run(actual.Name, func(t *testing.T) {
@ -317,3 +318,44 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
{
Name: "provider-with-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "00000000-0000-0000-0000-000000000001"}},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000001"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &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)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@ -3,6 +3,7 @@ package pagerduty
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -11,15 +12,38 @@ import (
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr" "github.com/TwiN/logr"
"gopkg.in/yaml.v3"
) )
const ( const (
restAPIURL = "https://events.pagerduty.com/v2/enqueue" restAPIURL = "https://events.pagerduty.com/v2/enqueue"
) )
var (
ErrIntegrationKeyNotSet = errors.New("integration-key must have exactly 32 characters")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
IntegrationKey string `yaml:"integration-key"`
}
func (cfg *Config) Validate() error {
if len(cfg.IntegrationKey) != 32 {
return ErrIntegrationKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.IntegrationKey) > 0 {
cfg.IntegrationKey = override.IntegrationKey
}
}
// AlertProvider is the configuration necessary for sending an alert using PagerDuty // AlertProvider is the configuration necessary for sending an alert using PagerDuty
type AlertProvider struct { type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -31,29 +55,33 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
IntegrationKey string `yaml:"integration-key"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
// Either the default integration key has the right length, or there are overrides who are properly configured. // Either the default integration key has the right length, or there are overrides who are properly configured.
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) 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, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@ -100,7 +128,7 @@ type Payload struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, eventAction, resolveKey string var message, eventAction, resolveKey string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@ -112,7 +140,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
resolveKey = "" resolveKey = ""
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(ep.Group), RoutingKey: cfg.IntegrationKey,
DedupKey: resolveKey, DedupKey: resolveKey,
EventAction: eventAction, EventAction: eventAction,
Payload: Payload{ Payload: Payload{
@ -124,23 +152,42 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return body return body
} }
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.IntegrationKey
}
}
}
return provider.IntegrationKey
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
type pagerDutyResponsePayload struct { type pagerDutyResponsePayload struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`

View File

@ -11,50 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{IntegrationKey: ""} invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"} validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{ Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideIntegrationKey := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideIntegrationKey.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{ Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid, got error:", err.Error())
} }
} }
@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description}, Alert: alert.Alert{Description: &description},
Resolved: false, Resolved: false,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}", ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description, ResolveKey: "key"}, Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true, Resolved: true,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}", ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
@ -161,7 +152,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved) body := scenario.Provider.buildRequestBody(&scenario.Provider.DefaultConfig, &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
@ -173,69 +164,6 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000002",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
@ -244,3 +172,94 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be 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{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
},
}
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.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
}
// 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

@ -29,18 +29,26 @@ import (
// AlertProvider is the interface that each provider should implement // AlertProvider is the interface that each provider should implement
type AlertProvider interface { type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
IsValid() bool Validate() error
// Send an alert using the provider
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert GetDefaultAlert() *alert.Alert
// Send an alert using the provider // ValidateOverrides validates the alert's provider override and, if present, the group override
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error ValidateOverrides(group string, alert *alert.Alert) error
} }
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline type Config[T any] interface {
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) { Validate() error
Merge(override *T)
}
// MergeProviderDefaultAlertIntoEndpointAlert parses an Endpoint alert by using the provider's default alert as a baseline
func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
if providerDefaultAlert == nil || endpointAlert == nil { if providerDefaultAlert == nil || endpointAlert == nil {
return return
} }
@ -62,14 +70,14 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
} }
var ( var (
// Validate interface implementation on compile // Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil) _ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil) _ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil) _ AlertProvider = (*matrix.AlertProvider)(nil)
@ -85,4 +93,28 @@ var (
_ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*zulip.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[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[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
_ Config[pushover.Config] = (*pushover.Config)(nil)
_ Config[slack.Config] = (*slack.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[zulip.Config] = (*zulip.Config)(nil)
) )

View File

@ -126,7 +126,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert) MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil { if scenario.ExpectedOutputAlert == nil {
if scenario.EndpointAlert != nil { if scenario.EndpointAlert != nil {
t.Fail() t.Fail()

View File

@ -3,6 +3,7 @@ package pushover
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,6 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
@ -17,8 +19,13 @@ const (
defaultPriority = 0 defaultPriority = 0
) )
// AlertProvider is the configuration necessary for sending an alert using Pushover var (
type AlertProvider struct { ErrInvalidApplicationToken = errors.New("application-token must be 30 characters long")
ErrInvalidUserKey = errors.New("user-key must be 30 characters long")
ErrInvalidPriority = errors.New("priority and resolved-priority must be between -2 and 2")
)
type Config struct {
// Key used to authenticate the application sending // Key used to authenticate the application sending
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build // See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"` ApplicationToken string `yaml:"application-token"`
@ -41,26 +48,69 @@ type AlertProvider struct {
// Sound of the messages (see: https://pushover.net/api#sounds) // Sound of the messages (see: https://pushover.net/api#sounds)
// default: "" (pushover) // default: "" (pushover)
Sound string `yaml:"sound,omitempty"` Sound string `yaml:"sound,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = defaultPriority
}
if cfg.ResolvedPriority == 0 {
cfg.ResolvedPriority = defaultPriority
}
if len(cfg.ApplicationToken) != 30 {
return ErrInvalidApplicationToken
}
if len(cfg.UserKey) != 30 {
return ErrInvalidUserKey
}
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
return ErrInvalidPriority
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ApplicationToken) > 0 {
cfg.ApplicationToken = override.ApplicationToken
}
if len(override.UserKey) > 0 {
cfg.UserKey = override.UserKey
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if override.ResolvedPriority != 0 {
cfg.ResolvedPriority = override.ResolvedPriority
}
if len(override.Sound) > 0 {
cfg.Sound = override.Sound
}
}
// AlertProvider is the configuration necessary for sending an alert using Pushover
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.Priority == 0 { return provider.DefaultConfig.Validate()
provider.Priority = defaultPriority
}
if provider.ResolvedPriority == 0 {
provider.ResolvedPriority = defaultPriority
}
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2 && provider.ResolvedPriority >= -2 && provider.ResolvedPriority <= 2
} }
// Send an alert using the provider // Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api // Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) 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, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@ -88,38 +138,51 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
priority := cfg.Priority
if resolved {
priority = cfg.ResolvedPriority
}
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Token: provider.ApplicationToken, Token: cfg.ApplicationToken,
User: provider.UserKey, User: cfg.UserKey,
Title: provider.Title, Title: cfg.Title,
Message: message, Message: message,
Priority: provider.priority(resolved), Priority: priority,
Sound: provider.Sound, Sound: cfg.Sound,
}) })
return body return body
} }
func (provider *AlertProvider) priority(resolved bool) int {
if resolved && provider.ResolvedPriority == 0 {
return defaultPriority
}
if !resolved && provider.Priority == 0 {
return defaultPriority
}
if resolved {
return provider.ResolvedPriority
}
return provider.Priority
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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

@ -12,31 +12,38 @@ import (
) )
func TestPushoverAlertProvider_IsValid(t *testing.T) { func TestPushoverAlertProvider_IsValid(t *testing.T) {
t.Run("empty-invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{ validProvider := AlertProvider{
DefaultConfig: Config{
ApplicationToken: "aTokenWithLengthOf30characters", ApplicationToken: "aTokenWithLengthOf30characters",
UserKey: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters",
Title: "Gatus Notification", Title: "Gatus Notification",
Priority: 1, Priority: 1,
ResolvedPriority: 1, ResolvedPriority: 1,
},
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} })
t.Run("invalid-provider", func(t *testing.T) {
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
invalidProvider := AlertProvider{ invalidProvider := AlertProvider{
DefaultConfig: Config{
ApplicationToken: "aTokenWithLengthOfMoreThan30characters", ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
UserKey: "aTokenWithLengthOfMoreThan30characters", UserKey: "aTokenWithLengthOfMoreThan30characters",
Priority: 5, Priority: 5,
},
} }
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider should've been invalid") t.Error("provider should've been invalid")
} }
})
} }
func TestAlertProvider_Send(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
@ -53,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -63,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -73,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -83,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -129,28 +136,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
}, },
{ {
Name: "resolved-priority", Name: "resolved-priority",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":0}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":0}",
}, },
{ {
Name: "with-sound", Name: "with-sound",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
@ -159,6 +166,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -188,3 +196,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be 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{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"application-token": "TokenWithLengthOf30Characters2", "user-key": "TokenWithLengthOf30Characters3"}},
ExpectedOutput: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters3"},
},
}
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.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {
t.Errorf("expected application token to be %s, got %s", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)
}
if got.UserKey != scenario.ExpectedOutput.UserKey {
t.Errorf("expected user key to be %s, got %s", scenario.ExpectedOutput.UserKey, got.UserKey)
}
// 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

@ -3,6 +3,7 @@ package slack
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,13 +11,38 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"` // Slack 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 Slack // AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
} }
@ -24,27 +50,31 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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(ep, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
@ -126,19 +156,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "https://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -227,64 +227,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", 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 _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput) 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)
} }
}) })
} }

View File

@ -3,6 +3,7 @@ package teams
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,54 +11,85 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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 // AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` 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 // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) 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 { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@ -84,7 +116,7 @@ type Section struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { 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) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@ -111,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Type: "MessageCard", Type: "MessageCard",
Context: "http://schema.org/extensions", Context: "http://schema.org/extensions",
ThemeColor: color, ThemeColor: color,
Title: provider.Title, Title: cfg.Title,
Text: message + description, Text: message + description,
} }
if len(body.Title) == 0 { if len(body.Title) == 0 {
@ -127,19 +159,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", 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 _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) 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)
} }
}) })
} }

View File

@ -3,6 +3,7 @@ package teamsworkflows
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,46 +11,74 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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
}
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.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Teams // AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` 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 // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) 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 { if err != nil {
return err return err
} }
@ -106,7 +135,7 @@ type Fact struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
var themeColor string var themeColor string
if resolved { if resolved {
@ -119,8 +148,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
// Configure default title if it's not provided // Configure default title if it's not provided
title := "&#x26D1; Gatus" title := "&#x26D1; Gatus"
if provider.Title != "" { if cfg.Title != "" {
title = provider.Title title = cfg.Title
} }
// Build the facts from the condition results // Build the facts from the condition results
@ -189,19 +218,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid, got", err.Error())
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", 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 _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) 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)
} }
}) })
} }

View File

@ -3,6 +3,7 @@ package telegram
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,66 +11,97 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const defaultAPIURL = "https://api.telegram.org" const defaultApiUrl = "https://api.telegram.org"
var (
ErrTokenNotSet = errors.New("token not set")
ErrIDNotSet = errors.New("id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Token string `yaml:"token"`
ID string `yaml:"id"`
ApiUrl string `yaml:"api-url"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.ApiUrl) == 0 {
cfg.ApiUrl = defaultApiUrl
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
if len(cfg.ID) == 0 {
return ErrIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.ID) > 0 {
cfg.ID = override.ID
}
if len(override.ApiUrl) > 0 {
cfg.ApiUrl = override.ApiUrl
}
}
// AlertProvider is the configuration necessary for sending an alert using Telegram // AlertProvider is the configuration necessary for sending an alert using Telegram
type AlertProvider struct { type AlertProvider struct {
Token string `yaml:"token"` DefaultConfig Config `yaml:",inline"`
ID string `yaml:"id"`
APIURL string `yaml:"api-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Overrid that may be prioritized over the default configuration // Overrides is a list of overrides that may be prioritized over the default configuration
Overrides []*Override `yaml:"overrides,omitempty"` Overrides []*Override `yaml:"overrides,omitempty"`
} }
// Override is a configuration that may be prioritized over the default configuration // Override is a configuration that may be prioritized over the default configuration
type Override struct { type Override struct {
group string `yaml:"group"` Group string `yaml:"group"`
token string `yaml:"token"` Config `yaml:",inline"`
id string `yaml:"id"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil { registeredGroups := make(map[string]bool)
provider.ClientConfig = client.GetDefaultConfig() if provider.Overrides != nil {
}
registerGroups := make(map[string]bool)
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if len(override.group) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
if _, ok := registerGroups[override.group]; ok { registeredGroups[override.Group] = true
return false
} }
registerGroups[override.group] = true
} }
return provider.DefaultConfig.Validate()
return len(provider.Token) > 0 && len(provider.ID) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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)) cfg, err := provider.GetConfig(ep.Group, alert)
apiURL := provider.APIURL if err != nil {
if apiURL == "" { return err
apiURL = defaultAPIURL
} }
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.getTokenForGroup(ep.Group)), buffer) buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", cfg.ApiUrl, cfg.Token), buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@ -81,15 +113,6 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err return err
} }
func (provider *AlertProvider) getTokenForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.token) > 0 {
return override.token
}
}
return provider.Token
}
type Body struct { type Body struct {
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Text string `json:"text"` Text string `json:"text"`
@ -97,7 +120,7 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
@ -124,23 +147,45 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults) text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
} }
bodyAsJSON, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
ChatID: provider.getIDForGroup(ep.Group), ChatID: cfg.ID,
Text: text, Text: text,
ParseMode: "MARKDOWN", ParseMode: "MARKDOWN",
}) })
return bodyAsJSON return bodyAsJSON
} }
func (provider *AlertProvider) getIDForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.id) > 0 {
return override.id
}
}
return provider.ID
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -11,87 +11,36 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""} invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
}) })
}
func TestAlertProvider_IsValidWithOverrides(t *testing.T) {
t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) { t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{token: "token", id: "id"}}} invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) { t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group1", token: "token", id: "id"}, {group: "group1", id: "id2"}}} invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group1", Config: Config{Token: "token", ID: "id"}}, {Group: "group1", Config: Config{ID: "id2"}}}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider-with-overrides", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "token", id: "id"}}} validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "token", ID: "id"}}}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
}
func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != provider.Token {
t.Error("token should have been the default token")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != provider.ID {
t.Error("id should have been the default id")
}
}) })
} }
@ -109,7 +58,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -119,7 +68,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -129,7 +78,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -139,7 +88,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@ -185,14 +134,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}", ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}", ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
@ -200,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved-with-no-conditions", Name: "resolved-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}", ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
@ -216,6 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
@ -240,3 +190,63 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken", ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != provider.DefaultConfig.Token {
t.Error("token should have been the default token")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
})
t.Run("get-default-token-with-overridden-token-and-alert-token-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
alert := &alert.Alert{ProviderOverride: map[string]any{"token": "alertToken"}}
cfg, err := provider.GetConfig("group", alert)
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "alertToken" {
t.Error("token should have been 'alertToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = provider.ValidateOverrides("group", alert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}

View File

@ -3,6 +3,7 @@ package twilio
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -11,33 +12,80 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Twilio var (
type AlertProvider struct { ErrSIDNotSet = errors.New("sid not set")
ErrTokenNotSet = errors.New("token not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
)
type Config struct {
SID string `yaml:"sid"` SID string `yaml:"sid"`
Token string `yaml:"token"` Token string `yaml:"token"`
From string `yaml:"from"` From string `yaml:"from"`
To string `yaml:"to"` To string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.SID) == 0 {
return ErrSIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
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.SID) > 0 {
cfg.SID = override.SID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
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 Twilio
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved))) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", cfg.SID), buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token)))) request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.SID+":"+cfg.Token))))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@ -51,7 +99,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@ -59,8 +107,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
return url.Values{ return url.Values{
"To": {provider.To}, "To": {cfg.To},
"From": {provider.From}, "From": {cfg.From},
"Body": {message}, "Body": {message},
}.Encode() }.Encode()
} }
@ -69,3 +117,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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

@ -1,28 +1,110 @@
package twilio package twilio
import ( import (
"net/http"
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "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/config/endpoint"
"github.com/TwiN/gatus/v5/test"
) )
func TestTwilioAlertProvider_IsValid(t *testing.T) { func TestTwilioAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
DefaultConfig: Config{
SID: "1", SID: "1",
Token: "1", Token: "1",
From: "1", From: "1",
To: "1", To: "1",
},
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") 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{SID: "1", Token: "2", From: "3", To: "4"}},
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{SID: "1", Token: "2", From: "3", To: "4"}},
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{SID: "1", Token: "2", From: "3", To: "4"}},
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{SID: "1", Token: "2", From: "3", To: "4"}},
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) { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
secondDescription := "description-2" secondDescription := "description-2"
@ -35,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4", ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4", ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
@ -51,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@ -76,3 +159,53 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"sid": "5", "token": "6", "from": "7", "to": "8"}},
ExpectedOutput: Config{SID: "5", Token: "6", From: "7", To: "8"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.SID != scenario.ExpectedOutput.SID {
t.Errorf("expected SID to be %s, got %s", scenario.ExpectedOutput.SID, got.SID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
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.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@ -2,6 +2,7 @@ package zulip
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,108 +11,99 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrBotEmailNotSet = errors.New("bot-email not set")
ErrBotAPIKeyNotSet = errors.New("bot-api-key not set")
ErrDomainNotSet = errors.New("domain not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
) )
type Config struct { type Config struct {
// BotEmail is the email of the bot user BotEmail string `yaml:"bot-email"` // Email of the bot user
BotEmail string `yaml:"bot-email"` BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
// BotAPIKey is the API key of the bot user Domain string `yaml:"domain"` // Domain of the Zulip server
BotAPIKey string `yaml:"bot-api-key"` ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
// Domain is the domain of the Zulip server }
Domain string `yaml:"domain"`
// ChannelID is the ID of the channel to send the message to func (cfg *Config) Validate() error {
ChannelID string `yaml:"channel-id"` if len(cfg.BotEmail) == 0 {
return ErrBotEmailNotSet
}
if len(cfg.BotAPIKey) == 0 {
return ErrBotAPIKeyNotSet
}
if len(cfg.Domain) == 0 {
return ErrDomainNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.BotEmail) > 0 {
cfg.BotEmail = override.BotEmail
}
if len(override.BotAPIKey) > 0 {
cfg.BotAPIKey = override.BotAPIKey
}
if len(override.Domain) > 0 {
cfg.Domain = override.Domain
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
} }
// AlertProvider is the configuration necessary for sending an alert using Zulip // AlertProvider is the configuration necessary for sending an alert using Zulip
type AlertProvider struct { type AlertProvider struct {
Config `yaml:",inline"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
} }
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Config
Group string `yaml:"group"` Group string `yaml:"group"`
Config `yaml:",inline"`
} }
func (provider *AlertProvider) validateConfig(conf *Config) bool { // Validate the provider's configuration
return len(conf.BotEmail) > 0 && len(conf.BotAPIKey) > 0 && len(conf.Domain) > 0 && len(conf.ChannelID) > 0 func (provider *AlertProvider) Validate() error {
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
isAlreadyRegistered := registeredGroups[override.Group] if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
if isAlreadyRegistered || override.Group == "" || !provider.validateConfig(&override.Config) { return ErrDuplicateGroupOverride
return false
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return provider.validateConfig(&provider.Config) return provider.DefaultConfig.Validate()
}
// getChannelIdForGroup returns the channel ID for the provided group
func (provider *AlertProvider) getChannelIdForGroup(group string) string {
for _, override := range provider.Overrides {
if override.Group == group {
return override.ChannelID
}
}
return provider.ChannelID
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
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)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n> " + alertDescription + "\n"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":check:"
} else {
prefix = ":cross_mark:"
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
postData := map[string]string{
"type": "channel",
"to": provider.getChannelIdForGroup(ep.Group),
"topic": "Gatus",
"content": message,
}
bodyParams := url.Values{}
for field, value := range postData {
bodyParams.Add(field, value)
}
return bodyParams.Encode()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBufferString(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", provider.Domain) if err != nil {
return err
}
buffer := bytes.NewBufferString(provider.buildRequestBody(cfg, ep, alert, result, resolved))
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", cfg.Domain)
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer) request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
if err != nil { if err != nil {
return err return err
} }
request.SetBasicAuth(provider.BotEmail, provider.BotAPIKey) request.SetBasicAuth(cfg.BotEmail, cfg.BotAPIKey)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Gatus") request.Header.Set("User-Agent", "Gatus")
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
@ -126,7 +118,66 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return nil return nil
} }
// 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) string {
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)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n> " + alertDescription + "\n"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":check:"
} else {
prefix = ":cross_mark:"
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
return url.Values{
"type": {"channel"},
"to": {cfg.ChannelID},
"topic": {"Gatus"},
"content": {message},
}.Encode()
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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

@ -1,6 +1,7 @@
package zulip package zulip
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -12,237 +13,84 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
testCase := []struct { scenarios := []struct {
name string Name string
alertProvider AlertProvider AlertProvider AlertProvider
expected bool ExpectedError error
}{ }{
{ {
name: "Empty provider", Name: "Empty provider",
alertProvider: AlertProvider{}, AlertProvider: AlertProvider{},
expected: false, ExpectedError: ErrBotEmailNotSet,
}, },
{ {
name: "Empty channel id", Name: "Empty channel id",
alertProvider: AlertProvider{ AlertProvider: AlertProvider{
Config: Config{ DefaultConfig: Config{
BotEmail: "something", BotEmail: "something",
BotAPIKey: "something", BotAPIKey: "something",
Domain: "something", Domain: "something",
}, },
}, },
expected: false, ExpectedError: ErrChannelIDNotSet,
}, },
{ {
name: "Empty domain", Name: "Empty domain",
alertProvider: AlertProvider{ AlertProvider: AlertProvider{
Config: Config{ DefaultConfig: Config{
BotEmail: "something", BotEmail: "something",
BotAPIKey: "something", BotAPIKey: "something",
ChannelID: "something", ChannelID: "something",
}, },
}, },
expected: false, ExpectedError: ErrDomainNotSet,
}, },
{ {
name: "Empty bot api key", Name: "Empty bot api key",
alertProvider: AlertProvider{ AlertProvider: AlertProvider{
Config: Config{ DefaultConfig: Config{
BotEmail: "something", BotEmail: "something",
Domain: "something", Domain: "something",
ChannelID: "something", ChannelID: "something",
}, },
}, },
expected: false, ExpectedError: ErrBotAPIKeyNotSet,
}, },
{ {
name: "Empty bot email", Name: "Empty bot email",
alertProvider: AlertProvider{ AlertProvider: AlertProvider{
Config: Config{ DefaultConfig: Config{
BotAPIKey: "something", BotAPIKey: "something",
Domain: "something", Domain: "something",
ChannelID: "something", ChannelID: "something",
}, },
}, },
expected: false, ExpectedError: ErrBotEmailNotSet,
}, },
{ {
name: "Valid provider", Name: "Valid provider",
alertProvider: AlertProvider{ AlertProvider: AlertProvider{
Config: Config{ DefaultConfig: Config{
BotEmail: "something", BotEmail: "something",
BotAPIKey: "something", BotAPIKey: "something",
Domain: "something", Domain: "something",
ChannelID: "something", ChannelID: "something",
}, },
}, },
expected: true, ExpectedError: nil,
}, },
} }
for _, tc := range testCase { for _, scenario := range scenarios {
t.Run(tc.name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if tc.alertProvider.IsValid() != tc.expected { if err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected) t.Errorf("ExpectedError error %v, got %v", scenario.ExpectedError, err)
} }
}) })
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
validConfig := Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
}
testCase := []struct {
name string
alertProvider AlertProvider
expected bool
}{
{
name: "Empty group",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Config: validConfig,
Group: "",
},
},
},
expected: false,
},
{
name: "Empty override config",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
},
},
},
expected: false,
},
{
name: "Empty channel id",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
},
},
expected: false,
},
{
name: "Empty domain",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Empty bot api key",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Empty bot email",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Valid provider",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: validConfig,
},
},
},
expected: true,
},
}
for _, tc := range testCase {
t.Run(tc.name, func(t *testing.T) {
if tc.alertProvider.IsValid() != tc.expected {
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected)
}
})
}
}
func TestAlertProvider_GetChannelIdForGroup(t *testing.T) {
provider := AlertProvider{
Config: Config{
ChannelID: "default",
},
Overrides: []Override{
{
Group: "group1",
Config: Config{ChannelID: "group1"},
},
{
Group: "group2",
Config: Config{ChannelID: "group2"},
},
},
}
if provider.getChannelIdForGroup("") != "default" {
t.Error("Expected default channel ID")
}
if provider.getChannelIdForGroup("group2") != "group2" {
t.Error("Expected group2 channel ID")
}
}
func TestAlertProvider_BuildRequestBody(t *testing.T) {
basicConfig := Config{ basicConfig := Config{
BotEmail: "bot-email", BotEmail: "bot-email",
BotAPIKey: "bot-api-key", BotAPIKey: "bot-api-key",
@ -266,13 +114,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{ {
name: "Resolved alert with no conditions", name: "Resolved alert with no conditions",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: true, resolved: true,
hasConditions: false, hasConditions: false,
expectedBody: url.Values{ expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row "content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description > Description
`}, `},
"to": {"channel-id"}, "to": {"channel-id"},
@ -283,13 +131,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{ {
name: "Resolved alert with conditions", name: "Resolved alert with conditions",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: true, resolved: true,
hasConditions: true, hasConditions: true,
expectedBody: url.Values{ expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row "content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description > Description
:check: - ` + "`[CONNECTED] == true`" + ` :check: - ` + "`[CONNECTED] == true`" + `
@ -303,13 +151,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{ {
name: "Failed alert with no conditions", name: "Failed alert with no conditions",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: false, resolved: false,
hasConditions: false, hasConditions: false,
expectedBody: url.Values{ expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row "content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description > Description
`}, `},
"to": {"channel-id"}, "to": {"channel-id"},
@ -320,13 +168,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{ {
name: "Failed alert with conditions", name: "Failed alert with conditions",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: false, resolved: false,
hasConditions: true, hasConditions: true,
expectedBody: url.Values{ expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row "content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description > Description
:cross_mark: - ` + "`[CONNECTED] == true`" + ` :cross_mark: - ` + "`[CONNECTED] == true`" + `
@ -349,7 +197,8 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
} }
} }
body := tc.provider.buildRequestBody( body := tc.provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"}, &tc.provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert, &tc.alert,
&endpoint.Result{ &endpoint.Result{
ConditionResults: conditionResults, ConditionResults: conditionResults,
@ -369,10 +218,10 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("ExpectedError default alert to be not nil")
} }
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("ExpectedError default alert to be nil")
} }
} }
@ -380,16 +229,16 @@ func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
validateRequest := func(req *http.Request) { validateRequest := func(req *http.Request) {
if req.URL.String() != "https://custom-domain/api/v1/messages" { if req.URL.String() != "https://custom-domain/api/v1/messages" {
t.Errorf("expected url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String()) t.Errorf("ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
} }
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
t.Errorf("expected POST request, got %s", req.Method) t.Errorf("ExpectedError POST request, got %s", req.Method)
} }
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("expected Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type")) t.Errorf("ExpectedError Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
} }
if req.Header.Get("User-Agent") != "Gatus" { if req.Header.Get("User-Agent") != "Gatus" {
t.Errorf("expected User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent")) t.Errorf("ExpectedError User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
} }
} }
basicConfig := Config{ basicConfig := Config{
@ -413,7 +262,7 @@ func TestAlertProvider_Send(t *testing.T) {
{ {
name: "resolved", name: "resolved",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: true, resolved: true,
@ -426,7 +275,7 @@ func TestAlertProvider_Send(t *testing.T) {
{ {
name: "resolved error", name: "resolved error",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: true, resolved: true,
@ -439,7 +288,7 @@ func TestAlertProvider_Send(t *testing.T) {
{ {
name: "triggered", name: "triggered",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: false, resolved: false,
@ -452,7 +301,7 @@ func TestAlertProvider_Send(t *testing.T) {
{ {
name: "triggered error", name: "triggered error",
provider: AlertProvider{ provider: AlertProvider{
Config: basicConfig, DefaultConfig: basicConfig,
}, },
alert: basicAlert, alert: basicAlert,
resolved: false, resolved: false,
@ -467,7 +316,7 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send( err := tc.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert, &tc.alert,
&endpoint.Result{ &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
@ -478,10 +327,155 @@ func TestAlertProvider_Send(t *testing.T) {
tc.resolved, tc.resolved,
) )
if tc.expectedError && err == nil { if tc.expectedError && err == nil {
t.Error("expected error, got none") t.Error("ExpectedError error, got none")
} }
if !tc.expectedError && err != nil { if !tc.expectedError && err != nil {
t.Errorf("expected no error, got: %v", err) t.Errorf("ExpectedError no error, got: %v", err)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-overrides",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel-id"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"bot-email": "alert-bot-email",
"bot-api-key": "alert-bot-api-key",
"domain": "alert-domain",
"channel-id": "alert-channel-id",
}},
ExpectedOutput: Config{
BotEmail: "alert-bot-email",
BotAPIKey: "alert-bot-api-key",
Domain: "alert-domain",
ChannelID: "alert-channel-id",
},
},
}
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.BotEmail != scenario.ExpectedOutput.BotEmail {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotEmail, got.BotEmail)
}
if got.BotAPIKey != scenario.ExpectedOutput.BotAPIKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotAPIKey, got.BotAPIKey)
}
if got.Domain != scenario.ExpectedOutput.Domain {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Domain, got.Domain)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
// 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

@ -22,6 +22,7 @@ import (
"github.com/TwiN/gatus/v5/security" "github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage" "github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/logr" "github.com/TwiN/logr"
"github.com/gofiber/fiber/v2/log"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -421,14 +422,20 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, alertType := range alertTypes { for _, alertType := range alertTypes {
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType) alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
if alertProvider != nil { if alertProvider != nil {
if alertProvider.IsValid() { if err := alertProvider.Validate(); err == nil {
// Parse alerts with the provider's default alert // Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil { if alertProvider.GetDefaultAlert() != nil {
for _, ep := range endpoints { for _, ep := range endpoints {
for alertIndex, endpointAlert := range ep.Alerts { for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type { if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key()) logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert) provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
}
}
} }
} }
} }
@ -436,14 +443,20 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for alertIndex, endpointAlert := range ee.Alerts { for alertIndex, endpointAlert := range ee.Alerts {
if alertType == endpointAlert.Type { if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key()) logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert) provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
}
}
} }
} }
} }
} }
validProviders = append(validProviders, alertType) validProviders = append(validProviders, alertType)
} else { } else {
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType) logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
invalidProviders = append(invalidProviders, alertType) invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider) alertingConfig.SetAlertingProviderToNil(alertProvider)
} }

View File

@ -11,10 +11,13 @@ import (
"github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider" "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/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email" "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/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "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/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
@ -30,6 +33,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/config/web"
@ -198,8 +202,8 @@ endpoints:
expectedConfig: &Config{ expectedConfig: &Config{
Metrics: true, Metrics: true,
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}, Discord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}},
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz", DefaultAlert: &alert.Alert{Enabled: &yes}}, Slack: &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, DefaultAlert: &alert.Alert{Enabled: &yes}},
}, },
ExternalEndpoints: []*endpoint.ExternalEndpoint{ ExternalEndpoints: []*endpoint.ExternalEndpoint{
{ {
@ -481,7 +485,7 @@ endpoints:
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
if config == nil { if config == nil {
t.Fatal("Config shouldn't have been nil") t.Fatal("DefaultConfig shouldn't have been nil")
} }
if config.Metrics { if config.Metrics {
t.Error("Metrics should've been false by default") t.Error("Metrics should've been false by default")
@ -786,7 +790,7 @@ endpoints:
if config.Alerting == nil { if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil") t.Fatal("config.Alerting shouldn't have been nil")
} }
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() { if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid") t.Fatal("Slack alerting config should've been valid")
} }
// Endpoints // Endpoints
@ -1037,63 +1041,64 @@ endpoints:
if config.Alerting == nil { if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil") t.Fatal("config.Alerting shouldn't have been nil")
} }
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid") t.Fatal("Slack alerting config should've been valid")
} }
if config.Alerting.Slack.GetDefaultAlert() == nil { if config.Alerting.Slack.GetDefaultAlert() == nil {
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Slack.WebhookURL != "http://example.com" { if config.Alerting.Slack.DefaultConfig.WebhookURL != "http://example.com" {
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.WebhookURL) t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.DefaultConfig.WebhookURL)
} }
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() { if config.Alerting.PagerDuty == nil || config.Alerting.PagerDuty.Validate() != nil {
t.Fatal("PagerDuty alerting config should've been valid") t.Fatal("PagerDuty alerting config should've been valid")
} }
if config.Alerting.PagerDuty.GetDefaultAlert() == nil { if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil") t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" { if config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != "00000000000000000000000000000000" {
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey) t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.DefaultConfig.IntegrationKey)
} }
if config.Alerting.Pushover == nil || !config.Alerting.Pushover.IsValid() { if config.Alerting.Pushover == nil || config.Alerting.Pushover.Validate() != nil {
t.Fatal("Pushover alerting config should've been valid") t.Fatal("Pushover alerting config should've been valid")
} }
if config.Alerting.Pushover.GetDefaultAlert() == nil { if config.Alerting.Pushover.GetDefaultAlert() == nil {
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Pushover.ApplicationToken != "000000000000000000000000000000" { if config.Alerting.Pushover.DefaultConfig.ApplicationToken != "000000000000000000000000000000" {
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.ApplicationToken) t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.ApplicationToken)
} }
if config.Alerting.Pushover.UserKey != "000000000000000000000000000000" { if config.Alerting.Pushover.DefaultConfig.UserKey != "000000000000000000000000000000" {
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.UserKey) t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.UserKey)
} }
if config.Alerting.Mattermost == nil || !config.Alerting.Mattermost.IsValid() { if config.Alerting.Mattermost == nil || config.Alerting.Mattermost.Validate() != nil {
t.Fatal("Mattermost alerting config should've been valid") t.Fatal("Mattermost alerting config should've been valid")
} }
if config.Alerting.Mattermost.GetDefaultAlert() == nil { if config.Alerting.Mattermost.GetDefaultAlert() == nil {
t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() { if config.Alerting.Messagebird == nil || config.Alerting.Messagebird.Validate() != nil {
t.Fatal("Messagebird alerting config should've been valid") t.Fatal("Messagebird alerting config should've been valid")
} }
if config.Alerting.Messagebird.GetDefaultAlert() == nil { if config.Alerting.Messagebird.GetDefaultAlert() == nil {
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Messagebird.AccessKey != "1" { if config.Alerting.Messagebird.DefaultConfig.AccessKey != "1" {
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey) t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.DefaultConfig.AccessKey)
} }
if config.Alerting.Messagebird.Originator != "31619191918" { if config.Alerting.Messagebird.DefaultConfig.Originator != "31619191918" {
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.Originator) t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.DefaultConfig.Originator)
} }
if config.Alerting.Messagebird.Recipients != "31619191919" { if config.Alerting.Messagebird.DefaultConfig.Recipients != "31619191919" {
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients) t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.DefaultConfig.Recipients)
} }
if config.Alerting.Discord == nil || !config.Alerting.Discord.IsValid() { if config.Alerting.Discord == nil || config.Alerting.Discord.Validate() != nil {
t.Fatal("Discord alerting config should've been valid") t.Fatal("Discord alerting config should've been valid")
} }
if config.Alerting.Discord.GetDefaultAlert() == nil { if config.Alerting.Discord.GetDefaultAlert() == nil {
@ -1105,98 +1110,98 @@ endpoints:
if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 { if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 {
t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold) t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold)
} }
if config.Alerting.Discord.WebhookURL != "http://example.org" { if config.Alerting.Discord.DefaultConfig.WebhookURL != "http://example.org" {
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL) t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.DefaultConfig.WebhookURL)
} }
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord { if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
t.Error("expected discord configuration") t.Error("expected discord configuration")
} }
if config.Alerting.Telegram == nil || !config.Alerting.Telegram.IsValid() { if config.Alerting.Telegram == nil || config.Alerting.Telegram.Validate() != nil {
t.Fatal("Telegram alerting config should've been valid") t.Fatal("Telegram alerting config should've been valid")
} }
if config.Alerting.Telegram.GetDefaultAlert() == nil { if config.Alerting.Telegram.GetDefaultAlert() == nil {
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Telegram.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" { if config.Alerting.Telegram.DefaultConfig.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.Token) t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.DefaultConfig.Token)
} }
if config.Alerting.Telegram.ID != "0123456789" { if config.Alerting.Telegram.DefaultConfig.ID != "0123456789" {
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.ID) t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.DefaultConfig.ID)
} }
if config.Alerting.Twilio == nil || !config.Alerting.Twilio.IsValid() { if config.Alerting.Twilio == nil || config.Alerting.Twilio.Validate() != nil {
t.Fatal("Twilio alerting config should've been valid") t.Fatal("Twilio alerting config should've been valid")
} }
if config.Alerting.Twilio.GetDefaultAlert() == nil { if config.Alerting.Twilio.GetDefaultAlert() == nil {
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Teams == nil || !config.Alerting.Teams.IsValid() { if config.Alerting.Teams == nil || config.Alerting.Teams.Validate() != nil {
t.Fatal("Teams alerting config should've been valid") t.Fatal("Teams alerting config should've been valid")
} }
if config.Alerting.Teams.GetDefaultAlert() == nil { if config.Alerting.Teams.GetDefaultAlert() == nil {
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() {
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
t.Fatal("JetBrainsSpace alerting config should've been valid") t.Fatal("JetBrainsSpace alerting config should've been valid")
} }
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil { if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil") t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.JetBrainsSpace.Project != "foo" { if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.Project) t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
} }
if config.Alerting.JetBrainsSpace.ChannelID != "bar" { if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.ChannelID) t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
} }
if config.Alerting.JetBrainsSpace.Token != "baz" { if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token) t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
} }
if config.Alerting.Email == nil || !config.Alerting.Email.IsValid() { if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
t.Fatal("Email alerting config should've been valid") t.Fatal("Email alerting config should've been valid")
} }
if config.Alerting.Email.GetDefaultAlert() == nil { if config.Alerting.Email.GetDefaultAlert() == nil {
t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Email.From != "from@example.com" { if config.Alerting.Email.DefaultConfig.From != "from@example.com" {
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.From) t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.From)
} }
if config.Alerting.Email.Username != "from@example.com" { if config.Alerting.Email.DefaultConfig.Username != "from@example.com" {
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.Username) t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.Username)
} }
if config.Alerting.Email.Password != "hunter2" { if config.Alerting.Email.DefaultConfig.Password != "hunter2" {
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.Password) t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.DefaultConfig.Password)
} }
if config.Alerting.Email.Host != "mail.example.com" { if config.Alerting.Email.DefaultConfig.Host != "mail.example.com" {
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.Host) t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.DefaultConfig.Host)
} }
if config.Alerting.Email.Port != 587 { if config.Alerting.Email.DefaultConfig.Port != 587 {
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.Port) t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.DefaultConfig.Port)
} }
if config.Alerting.Email.To != "recipient1@example.com,recipient2@example.com" { if config.Alerting.Email.DefaultConfig.To != "recipient1@example.com,recipient2@example.com" {
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.To) t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.DefaultConfig.To)
} }
if config.Alerting.Email.ClientConfig == nil { if config.Alerting.Email.DefaultConfig.ClientConfig == nil {
t.Fatal("Email client config should've been set") t.Fatal("Email client config should've been set")
} }
if config.Alerting.Email.ClientConfig.Insecure { if config.Alerting.Email.DefaultConfig.ClientConfig.Insecure {
t.Error("Email client config should've been secure") t.Error("Email client config should've been secure")
} }
if config.Alerting.Gotify == nil || !config.Alerting.Gotify.IsValid() { if config.Alerting.Gotify == nil || config.Alerting.Gotify.Validate() != nil {
t.Fatal("Gotify alerting config should've been valid") t.Fatal("Gotify alerting config should've been valid")
} }
if config.Alerting.Gotify.GetDefaultAlert() == nil { if config.Alerting.Gotify.GetDefaultAlert() == nil {
t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Gotify.ServerURL != "https://gotify.example" { if config.Alerting.Gotify.DefaultConfig.ServerURL != "https://gotify.example" {
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.ServerURL) t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.DefaultConfig.ServerURL)
} }
if config.Alerting.Gotify.Token != "**************" { if config.Alerting.Gotify.DefaultConfig.Token != "**************" {
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.Token) t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.DefaultConfig.Token)
} }
// External endpoints // External endpoints
@ -1405,6 +1410,8 @@ endpoints:
- type: slack - type: slack
enabled: false enabled: false
failure-threshold: 30 failure-threshold: 30
provider-override:
webhook-url: https://example.com
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
`)) `))
@ -1418,7 +1425,7 @@ endpoints:
if config.Alerting == nil { if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil") t.Fatal("config.Alerting shouldn't have been nil")
} }
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() { if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid") t.Fatal("Slack alerting config should've been valid")
} }
// Endpoints // Endpoints
@ -1546,17 +1553,18 @@ endpoints:
if config.Alerting.Custom == nil { if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil") t.Fatal("Custom alerting config shouldn't have been nil")
} }
if !config.Alerting.Custom.IsValid() { if err = config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid") t.Fatal("Custom alerting config should've been valid")
} }
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" { cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{ProviderOverride: map[string]any{"client": map[string]any{"insecure": true}}})
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(true)) if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true))
} }
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "TRIGGERED" { if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "TRIGGERED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(false)) t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false))
} }
if config.Alerting.Custom.ClientConfig.Insecure { if !cfg.ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", false, config.Alerting.Custom.ClientConfig.Insecure) t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, cfg.ClientConfig.Insecure)
} }
} }
@ -1583,7 +1591,7 @@ endpoints:
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
if config == nil { if config == nil {
t.Fatal("Config shouldn't have been nil") t.Fatal("DefaultConfig shouldn't have been nil")
} }
if config.Alerting == nil { if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil") t.Fatal("config.Alerting shouldn't have been nil")
@ -1591,13 +1599,14 @@ endpoints:
if config.Alerting.Custom == nil { if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil") t.Fatal("Custom alerting config shouldn't have been nil")
} }
if !config.Alerting.Custom.IsValid() { if err = config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid") t.Fatal("Custom alerting config should've been valid")
} }
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "operational" { cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "operational" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'") t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'")
} }
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" { if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'") t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
} }
} }
@ -1623,7 +1632,7 @@ endpoints:
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
if config == nil { if config == nil {
t.Fatal("Config shouldn't have been nil") t.Fatal("DefaultConfig shouldn't have been nil")
} }
if config.Alerting == nil { if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil") t.Fatal("config.Alerting shouldn't have been nil")
@ -1631,13 +1640,14 @@ endpoints:
if config.Alerting.Custom == nil { if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil") t.Fatal("Custom alerting config shouldn't have been nil")
} }
if !config.Alerting.Custom.IsValid() { if err := config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid") t.Fatal("Custom alerting config should've been valid")
} }
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" { cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'") t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'")
} }
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" { if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'") t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
} }
} }
@ -1813,7 +1823,7 @@ endpoints:
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
if config == nil { if config == nil {
t.Fatal("Config shouldn't have been nil") t.Fatal("DefaultConfig shouldn't have been nil")
} }
if config.Security == nil { if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil") t.Fatal("config.Security shouldn't have been nil")
@ -1846,7 +1856,7 @@ endpoints:
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
if config == nil { if config == nil {
t.Fatal("Config shouldn't have been nil") t.Fatal("DefaultConfig shouldn't have been nil")
} }
if config.Endpoints[0].URL != "https://twin.sh/health" { if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health") t.Errorf("URL should have been %s", "https://twin.sh/health")
@ -1868,10 +1878,13 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
func TestGetAlertingProviderByAlertType(t *testing.T) { func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{ alertingConfig := &alerting.Config{
AWSSimpleEmailService: &awsses.AlertProvider{},
Custom: &custom.AlertProvider{}, Custom: &custom.AlertProvider{},
Discord: &discord.AlertProvider{}, Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{}, Email: &email.AlertProvider{},
Gitea: &gitea.AlertProvider{},
GitHub: &github.AlertProvider{}, GitHub: &github.AlertProvider{},
GitLab: &gitlab.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{}, GoogleChat: &googlechat.AlertProvider{},
Gotify: &gotify.AlertProvider{}, Gotify: &gotify.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{}, JetBrainsSpace: &jetbrainsspace.AlertProvider{},
@ -1884,18 +1897,22 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
Pushover: &pushover.AlertProvider{}, Pushover: &pushover.AlertProvider{},
Slack: &slack.AlertProvider{}, Slack: &slack.AlertProvider{},
Telegram: &telegram.AlertProvider{}, Telegram: &telegram.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Teams: &teams.AlertProvider{}, Teams: &teams.AlertProvider{},
TeamsWorkflows: &teamsworkflows.AlertProvider{}, TeamsWorkflows: &teamsworkflows.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Zulip: &zulip.AlertProvider{},
} }
scenarios := []struct { scenarios := []struct {
alertType alert.Type alertType alert.Type
expected provider.AlertProvider expected provider.AlertProvider
}{ }{
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
{alertType: alert.TypeCustom, expected: alertingConfig.Custom}, {alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord}, {alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
{alertType: alert.TypeEmail, expected: alertingConfig.Email}, {alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub}, {alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat}, {alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify}, {alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace}, {alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
@ -1908,9 +1925,10 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypePushover, expected: alertingConfig.Pushover}, {alertType: alert.TypePushover, expected: alertingConfig.Pushover},
{alertType: alert.TypeSlack, expected: alertingConfig.Slack}, {alertType: alert.TypeSlack, expected: alertingConfig.Slack},
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram}, {alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams}, {alertType: alert.TypeTeams, expected: alertingConfig.Teams},
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows}, {alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(string(scenario.alertType), func(t *testing.T) { t.Run(string(scenario.alertType), func(t *testing.T) {

View File

@ -30,10 +30,12 @@ func TestHandleAlerting(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Custom: &custom.AlertProvider{ Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Method: "GET", Method: "GET",
}, },
}, },
},
} }
enabled := true enabled := true
ep := &endpoint.Endpoint{ ep := &endpoint.Endpoint{
@ -108,10 +110,12 @@ func TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButendpointStartFailing
cfg := &config.Config{ cfg := &config.Config{
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Custom: &custom.AlertProvider{ Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Method: "GET", Method: "GET",
}, },
}, },
},
} }
enabled := true enabled := true
ep := &endpoint.Endpoint{ ep := &endpoint.Endpoint{
@ -141,10 +145,12 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *t
cfg := &config.Config{ cfg := &config.Config{
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Custom: &custom.AlertProvider{ Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Method: "GET", Method: "GET",
}, },
}, },
},
} }
enabled := true enabled := true
disabled := false disabled := false
@ -174,9 +180,11 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Alerting: &alerting.Config{ Alerting: &alerting.Config{
PagerDuty: &pagerduty.AlertProvider{ PagerDuty: &pagerduty.AlertProvider{
DefaultConfig: pagerduty.Config{
IntegrationKey: "00000000000000000000000000000000", IntegrationKey: "00000000000000000000000000000000",
}, },
}, },
},
} }
enabled := true enabled := true
ep := &endpoint.Endpoint{ ep := &endpoint.Endpoint{
@ -208,10 +216,12 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPushover(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Pushover: &pushover.AlertProvider{ Pushover: &pushover.AlertProvider{
DefaultConfig: pushover.Config{
ApplicationToken: "000000000000000000000000000000", ApplicationToken: "000000000000000000000000000000",
UserKey: "000000000000000000000000000000", UserKey: "000000000000000000000000000000",
}, },
}, },
},
} }
enabled := true enabled := true
ep := &endpoint.Endpoint{ ep := &endpoint.Endpoint{
@ -250,25 +260,30 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeCustom, AlertType: alert.TypeCustom,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Custom: &custom.AlertProvider{ Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Method: "GET", Method: "GET",
}, },
}, },
}, },
},
{ {
Name: "discord", Name: "discord",
AlertType: alert.TypeDiscord, AlertType: alert.TypeDiscord,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Discord: &discord.AlertProvider{ Discord: &discord.AlertProvider{
DefaultConfig: discord.Config{
WebhookURL: "https://example.com", WebhookURL: "https://example.com",
}, },
}, },
}, },
},
{ {
Name: "email", Name: "email",
AlertType: alert.TypeEmail, AlertType: alert.TypeEmail,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Email: &email.AlertProvider{ Email: &email.AlertProvider{
DefaultConfig: email.Config{
From: "from@example.com", From: "from@example.com",
Password: "hunter2", Password: "hunter2",
Host: "mail.example.com", Host: "mail.example.com",
@ -277,89 +292,107 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
}, },
}, },
}, },
},
{ {
Name: "jetbrainsspace", Name: "jetbrainsspace",
AlertType: alert.TypeJetBrainsSpace, AlertType: alert.TypeJetBrainsSpace,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
JetBrainsSpace: &jetbrainsspace.AlertProvider{ JetBrainsSpace: &jetbrainsspace.AlertProvider{
DefaultConfig: jetbrainsspace.Config{
Project: "foo", Project: "foo",
ChannelID: "bar", ChannelID: "bar",
Token: "baz", Token: "baz",
}, },
}, },
}, },
},
{ {
Name: "mattermost", Name: "mattermost",
AlertType: alert.TypeMattermost, AlertType: alert.TypeMattermost,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Mattermost: &mattermost.AlertProvider{ Mattermost: &mattermost.AlertProvider{
DefaultConfig: mattermost.Config{
WebhookURL: "https://example.com", WebhookURL: "https://example.com",
}, },
}, },
}, },
},
{ {
Name: "messagebird", Name: "messagebird",
AlertType: alert.TypeMessagebird, AlertType: alert.TypeMessagebird,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Messagebird: &messagebird.AlertProvider{ Messagebird: &messagebird.AlertProvider{
DefaultConfig: messagebird.Config{
AccessKey: "1", AccessKey: "1",
Originator: "2", Originator: "2",
Recipients: "3", Recipients: "3",
}, },
}, },
}, },
},
{ {
Name: "pagerduty", Name: "pagerduty",
AlertType: alert.TypePagerDuty, AlertType: alert.TypePagerDuty,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
PagerDuty: &pagerduty.AlertProvider{ PagerDuty: &pagerduty.AlertProvider{
DefaultConfig: pagerduty.Config{
IntegrationKey: "00000000000000000000000000000000", IntegrationKey: "00000000000000000000000000000000",
}, },
}, },
}, },
},
{ {
Name: "pushover", Name: "pushover",
AlertType: alert.TypePushover, AlertType: alert.TypePushover,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Pushover: &pushover.AlertProvider{ Pushover: &pushover.AlertProvider{
DefaultConfig: pushover.Config{
ApplicationToken: "000000000000000000000000000000", ApplicationToken: "000000000000000000000000000000",
UserKey: "000000000000000000000000000000", UserKey: "000000000000000000000000000000",
}, },
}, },
}, },
},
{ {
Name: "slack", Name: "slack",
AlertType: alert.TypeSlack, AlertType: alert.TypeSlack,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Slack: &slack.AlertProvider{ Slack: &slack.AlertProvider{
DefaultConfig: slack.Config{
WebhookURL: "https://example.com", WebhookURL: "https://example.com",
}, },
}, },
}, },
},
{ {
Name: "teams", Name: "teams",
AlertType: alert.TypeTeams, AlertType: alert.TypeTeams,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Teams: &teams.AlertProvider{ Teams: &teams.AlertProvider{
DefaultConfig: teams.Config{
WebhookURL: "https://example.com", WebhookURL: "https://example.com",
}, },
}, },
}, },
},
{ {
Name: "telegram", Name: "telegram",
AlertType: alert.TypeTelegram, AlertType: alert.TypeTelegram,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Telegram: &telegram.AlertProvider{ Telegram: &telegram.AlertProvider{
DefaultConfig: telegram.Config{
Token: "1", Token: "1",
ID: "2", ID: "2",
}, },
}, },
}, },
},
{ {
Name: "twilio", Name: "twilio",
AlertType: alert.TypeTwilio, AlertType: alert.TypeTwilio,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Twilio: &twilio.AlertProvider{ Twilio: &twilio.AlertProvider{
DefaultConfig: twilio.Config{
SID: "1", SID: "1",
Token: "2", Token: "2",
From: "3", From: "3",
@ -367,12 +400,13 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
}, },
}, },
}, },
},
{ {
Name: "matrix", Name: "matrix",
AlertType: alert.TypeMatrix, AlertType: alert.TypeMatrix,
AlertingConfig: &alerting.Config{ AlertingConfig: &alerting.Config{
Matrix: &matrix.AlertProvider{ Matrix: &matrix.AlertProvider{
ProviderConfig: matrix.ProviderConfig{ DefaultConfig: matrix.Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@ -437,10 +471,12 @@ func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Custom: &custom.AlertProvider{ Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Method: "GET", Method: "GET",
}, },
}, },
},
} }
enabled := true enabled := true
ep := &endpoint.Endpoint{ ep := &endpoint.Endpoint{