diff --git a/.gitignore b/.gitignore
index 4f5f6b31..ae4df91d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,5 @@ node_modules
*.db-shm
*.db-wal
gatus
-config/config.yml
\ No newline at end of file
+config/config.yml
+config.yaml
\ No newline at end of file
diff --git a/README.md b/README.md
index 87668082..ed899680 100644
--- a/README.md
+++ b/README.md
@@ -72,8 +72,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- - [Configuring custom alerts](#configuring-custom-alerts)
- [Configuring Zulip alerts](#configuring-zulip-alerts)
+ - [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance)
- [Security](#security)
@@ -548,31 +548,47 @@ endpoints:
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
> ignored.
-| Parameter | Description | Default |
-|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------|
-| `alerting.custom` | Configuration for custom actions on failure or alerts.
See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
-| `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
-| `alerting.email` | Configuration for alerts of type `email`.
See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
-| `alerting.github` | Configuration for alerts of type `github`.
See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
-| `alerting.gitlab` | Configuration for alerts of type `gitlab`.
See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
-| `alerting.googlechat` | Configuration for alerts of type `googlechat`.
See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
-| `alerting.gotify` | Configuration for alerts of type `gotify`.
See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
-| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`.
See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
-| `alerting.matrix` | Configuration for alerts of type `matrix`.
See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
-| `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
-| `alerting.messagebird` | Configuration for alerts of type `messagebird`.
See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
-| `alerting.ntfy` | Configuration for alerts of type `ntfy`.
See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
-| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`.
See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
-| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
-| `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
-| `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
-| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
-| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
-| `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
-| `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
+| Parameter | Description | Default |
+|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
+| `alerting.awsses` | Configuration for alerts of type `awsses`.
See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
+| `alerting.custom` | Configuration for custom actions on failure or alerts.
See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
+| `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
+| `alerting.email` | Configuration for alerts of type `email`.
See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
+| `alerting.gitea` | Configuration for alerts of type `gitea`.
See [Configuring Gitea alerts](#configuring-gitea-alerts). | `{}` |
+| `alerting.github` | Configuration for alerts of type `github`.
See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
+| `alerting.gitlab` | Configuration for alerts of type `gitlab`.
See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
+| `alerting.googlechat` | Configuration for alerts of type `googlechat`.
See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
+| `alerting.gotify` | Configuration for alerts of type `gotify`.
See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
+| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`.
See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
+| `alerting.matrix` | Configuration for alerts of type `matrix`.
See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
+| `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
+| `alerting.messagebird` | Configuration for alerts of type `messagebird`.
See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
+| `alerting.ntfy` | Configuration for alerts of type `ntfy`.
See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
+| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`.
See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
+| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
+| `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
+| `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
+| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
+| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
+| `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
+| `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
+| `alerting.zulip` | Configuration for alerts of type `zulip`.
See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
#### Configuring Discord alerts
@@ -784,15 +800,14 @@ endpoints:
#### Configuring Google Chat alerts
-| Parameter | Description | Default |
-|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
-| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
-| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
-| `alerting.googlechat.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` |
-| `alerting.googlechat.default-alert` | Default alert configuration.
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[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
-| `alerting.googlechat.overrides[].webhook-url` | Google Chat Webhook URL | `""` |
+| Parameter | Description | Default |
+|:----------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
+| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
+| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
+| `alerting.googlechat.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` |
+| `alerting.googlechat.default-alert` | Default alert configuration.
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[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
```yaml
alerting:
@@ -925,7 +940,6 @@ endpoints:
| `alerting.mattermost.default-alert` | Default alert configuration.
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[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
-| `alerting.mattermist.overrides[].webhook-url` | Mattermost Webhook URL | `""` |
```yaml
alerting:
@@ -1332,8 +1346,6 @@ Here's an example of what the notifications look like:
| `alerting.telegram.default-alert` | Default alert configuration.
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[].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
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`.
+#### 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
| Parameter | Description | Default |
@@ -1591,42 +1636,6 @@ endpoints:
- 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
If you have maintenance windows, you may not want to be annoyed by alerts.
diff --git a/alerting/alert/alert.go b/alerting/alert/alert.go
index c97feef8..52eb5f5d 100644
--- a/alerting/alert/alert.go
+++ b/alerting/alert/alert.go
@@ -6,6 +6,9 @@ import (
"errors"
"strconv"
"strings"
+
+ "github.com/TwiN/logr"
+ "gopkg.in/yaml.v3"
)
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
// 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
//
// 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
- 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
// ongoing/triggered incidents
@@ -111,3 +118,11 @@ func (alert *Alert) Checksum() string {
)
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
+}
diff --git a/alerting/provider/awsses/awsses.go b/alerting/provider/awsses/awsses.go
index 955531a1..aa071450 100644
--- a/alerting/provider/awsses/awsses.go
+++ b/alerting/provider/awsses/awsses.go
@@ -1,30 +1,73 @@
package awsses
import (
+ "errors"
"fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"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/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
+ "gopkg.in/yaml.v3"
)
const (
CharSet = "UTF-8"
)
-// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
-type AlertProvider struct {
+var (
+ 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"`
SecretAccessKey string `yaml:"secret-access-key"`
Region string `yaml:"region"`
From string `yaml:"from"`
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 *alert.Alert `yaml:"default-alert,omitempty"`
@@ -35,36 +78,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- To string `yaml:"to"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
- return false
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- // 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 len(provider.From) > 0 && len(provider.To) > 0 &&
- ((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- sess, err := provider.createSession()
+ cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
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)
- emails := strings.Split(provider.getToForGroup(ep.Group), ",")
+ emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{
Destination: &ses.Destination{
@@ -82,26 +126,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
Data: aws.String(subject),
},
},
- Source: aws.String(provider.From),
+ Source: aws.String(cfg.From),
}
- _, err = svc.SendEmail(input)
-
- if err != nil {
+ if _, err = svc.SendEmail(input); err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
- fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
+ logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
- fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
+ logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
- fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
+ logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default:
- fmt.Println(aerr.Error())
+ logr.Error(aerr.Error())
}
} else {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
- fmt.Println(err.Error())
+ logr.Error(err.Error())
}
return err
@@ -109,6 +151,16 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
@@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
-func (provider *AlertProvider) createSession() (*session.Session, error) {
- config := &aws.Config{
- Region: aws.String(provider.Region),
+// 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
+ }
+ }
}
- if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
- config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
+ // 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)
}
- return session.NewSession(config)
+ // 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
}
diff --git a/alerting/provider/awsses/awsses_test.go b/alerting/provider/awsses/awsses_test.go
index 35b5eaf3..348a2db6 100644
--- a/alerting/provider/awsses/awsses_test.go
+++ b/alerting/provider/awsses/awsses_test.go
@@ -7,59 +7,61 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{}
- if invalidProvider.IsValid() {
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}
- if invalidProviderWithOneKey.IsValid() {
+ invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
+ if err := invalidProviderWithOneKey.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
- validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}
- if !validProviderWithKeys.IsValid() {
+ validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
+ if err := validProviderWithKeys.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- To: "to@example.com",
- Group: "",
+ Config: Config{To: "to@example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
- To: "",
- Group: "group",
+ Config: Config{To: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- From: "from@example.com",
- To: "to@example.com",
+ DefaultConfig: Config{
+ From: "from@example.com",
+ To: "to@example.com",
+ },
Overrides: []Override{
{
- To: "to@example.com",
- Group: "group",
+ Config: Config{To: "to@example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -124,64 +126,124 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getToForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- To: "to@example.com",
+ DefaultConfig: Config{
+ From: "from@example.com",
+ To: "to@example.com",
+ },
Overrides: nil,
},
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",
Provider: AlertProvider{
- To: "to@example.com",
+ DefaultConfig: Config{
+ From: "from@example.com",
+ To: "to@example.com",
+ },
Overrides: nil,
},
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",
Provider: AlertProvider{
- To: "to@example.com",
+ DefaultConfig: Config{
+ From: "from@example.com",
+ To: "to@example.com",
+ },
Overrides: []Override{
{
- Group: "group",
- To: "to01@example.com",
+ Group: "group",
+ Config: Config{To: "groupto@example.com"},
},
},
},
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",
Provider: AlertProvider{
- To: "to@example.com",
+ DefaultConfig: Config{
+ From: "from@example.com",
+ To: "to@example.com",
+ },
Overrides: []Override{
{
- Group: "group",
- To: "to01@example.com",
+ Group: "group",
+ Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ 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.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)
}
})
}
diff --git a/alerting/provider/custom/custom.go b/alerting/provider/custom/custom.go
index 91555547..c65e34a5 100644
--- a/alerting/provider/custom/custom.go
+++ b/alerting/provider/custom/custom.go
@@ -2,6 +2,7 @@ package custom
import (
"bytes"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,11 +11,14 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"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
-// Technically, all alert providers should be reachable using the custom alert provider
-type AlertProvider struct {
+var (
+ ErrURLNotSet = errors.New("url not set")
+)
+
+type Config struct {
URL string `yaml:"url"`
Method string `yaml:"method,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 *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 *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
-func (provider *AlertProvider) IsValid() bool {
- if provider.ClientConfig == nil {
- provider.ClientConfig = client.GetDefaultConfig()
- }
- return len(provider.URL) > 0 && provider.ClientConfig != nil
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
-func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
- status := "TRIGGERED"
- 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
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- request := provider.buildHTTPRequest(ep, alert, result, resolved)
- response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
+ response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
@@ -94,7 +98,82 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/custom/custom_test.go b/alerting/provider/custom/custom_test.go
index 67d94f65..cf2697cd 100644
--- a/alerting/provider/custom/custom_test.go
+++ b/alerting/provider/custom/custom_test.go
@@ -12,24 +12,18 @@ import (
"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) {
- invalidProvider := AlertProvider{URL: ""}
- if invalidProvider.IsValid() {
+ invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
- validProvider := AlertProvider{URL: "https://example.com"}
- if validProvider.ClientConfig != nil {
- t.Error("provider client config should have been nil prior to IsValid() being executed")
- }
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
+ if err := validProvider.Validate(); err != nil {
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",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
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) {
- customAlertProvider := &AlertProvider{
- 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]",
+ alertProvider := &AlertProvider{
+ DefaultConfig: Config{
+ 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]",
+ },
}
alertDescription := "alert-description"
scenarios := []struct {
@@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
ExpectedBody string
}{
{
- AlertProvider: customAlertProvider,
+ AlertProvider: alertProvider,
Resolved: true,
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",
},
{
- AlertProvider: customAlertProvider,
+ AlertProvider: alertProvider,
Resolved: false,
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",
@@ -137,7 +133,8 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
}
for _, scenario := range scenarios {
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"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
@@ -155,9 +152,11 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
}
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
- customAlertWithErrorsProvider := &AlertProvider{
- 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]",
+ 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]",
+ Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
+ },
}
alertDescription := "alert-description"
scenarios := []struct {
@@ -168,13 +167,13 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
Errors []string
}{
{
- AlertProvider: customAlertWithErrorsProvider,
+ AlertProvider: alertProvider,
Resolved: true,
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,",
},
{
- AlertProvider: customAlertWithErrorsProvider,
+ AlertProvider: alertProvider,
Resolved: false,
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",
@@ -183,7 +182,8 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
}
for _, scenario := range scenarios {
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"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors},
@@ -201,14 +201,16 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
- customAlertProvider := &AlertProvider{
- 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]",
- Headers: nil,
- Placeholders: map[string]map[string]string{
- "ALERT_TRIGGERED_OR_RESOLVED": {
- "RESOLVED": "fixed",
- "TRIGGERED": "boom",
+ alertProvider := &AlertProvider{
+ DefaultConfig: Config{
+ 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]",
+ Headers: nil,
+ Placeholders: map[string]map[string]string{
+ "ALERT_TRIGGERED_OR_RESOLVED": {
+ "RESOLVED": "fixed",
+ "TRIGGERED": "boom",
+ },
},
},
}
@@ -220,13 +222,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
ExpectedBody string
}{
{
- AlertProvider: customAlertProvider,
+ AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
},
{
- AlertProvider: customAlertProvider,
+ AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
@@ -234,7 +236,8 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
}
for _, scenario := range scenarios {
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"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{},
@@ -252,15 +255,17 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
}
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
- customAlertProvider := &AlertProvider{
- 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]",
+ alertProvider := &AlertProvider{
+ DefaultConfig: Config{
+ 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]",
+ },
}
- if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
- t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
+ if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
+ t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
}
- if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
- t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
+ if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
+ 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")
}
}
+
+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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/discord/discord.go b/alerting/provider/discord/discord.go
index fe302934..97f4e727 100644
--- a/alerting/provider/discord/discord.go
+++ b/alerting/provider/discord/discord.go
@@ -3,6 +3,7 @@ package discord
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,46 +11,73 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"`
+ Title string `yaml:"title,omitempty"` // Title of the message that will be sent
+}
+
+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
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 *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
-
- // Title is the title of the message that will be sent
- Title string `yaml:"title,omitempty"`
}
-// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- WebhookURL string `yaml:"webhook-url"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
- return false
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return len(provider.WebhookURL) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
- request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
@@ -85,7 +113,7 @@ type Field struct {
}
// 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 colorCode int
if resolved {
@@ -110,8 +138,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription
}
title := ":helmet_with_white_cross: Gatus"
- if provider.Title != "" {
- title = provider.Title
+ if cfg.Title != "" {
+ title = cfg.Title
}
body := Body{
Content: "",
@@ -134,19 +162,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON
}
-// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
-func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
- if provider.Overrides != nil {
- for _, override := range provider.Overrides {
- if group == override.Group {
- return override.WebhookURL
- }
- }
- }
- return provider.WebhookURL
-}
-
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// 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
+}
diff --git a/alerting/provider/discord/discord_test.go b/alerting/provider/discord/discord_test.go
index 14d2d9bf..20aaed6d 100644
--- a/alerting/provider/discord/discord_test.go
+++ b/alerting/provider/discord/discord_test.go
@@ -11,50 +11,52 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{WebhookURL: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{WebhookURL: "http://example.com"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "",
- Group: "group",
+ Config: Config{WebhookURL: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{
+ WebhookURL: "http://example.com",
+ },
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "group",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -74,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -84,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -94,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -104,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -114,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -122,6 +124,16 @@ func TestAlertProvider_Send(t *testing.T) {
}),
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 {
t.Run(scenario.Name, func(t *testing.T) {
@@ -175,7 +187,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "triggered-with-modified-title",
- Provider: AlertProvider{Title: title},
+ Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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}]}]}",
@@ -183,7 +195,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "triggered-with-no-conditions",
NoConditions: true,
- Provider: AlertProvider{Title: title},
+ Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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}]}",
@@ -200,6 +212,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
+ t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
}
})
}
diff --git a/alerting/provider/email/email.go b/alerting/provider/email/email.go
index 391d607d..79bece3f 100644
--- a/alerting/provider/email/email.go
+++ b/alerting/provider/email/email.go
@@ -2,6 +2,7 @@ package email
import (
"crypto/tls"
+ "errors"
"fmt"
"math"
"strings"
@@ -10,10 +11,17 @@ import (
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2"
+ "gopkg.in/yaml.v3"
)
-// AlertProvider is the configuration necessary for sending an alert using SMTP
-type AlertProvider struct {
+var (
+ 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"`
Username string `yaml:"username"`
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 *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 *alert.Alert `yaml:"default-alert,omitempty"`
@@ -33,54 +83,57 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- To string `yaml:"to"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
- return false
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
-
- return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
var username string
- if len(provider.Username) > 0 {
- username = provider.Username
+ if len(cfg.Username) > 0 {
+ username = cfg.Username
} else {
- username = provider.From
+ username = cfg.From
}
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage()
- m.SetHeader("From", provider.From)
- m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
+ m.SetHeader("From", cfg.From)
+ m.SetHeader("To", strings.Split(cfg.To, ",")...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
var d *gomail.Dialer
- if len(provider.Password) == 0 {
+ if len(cfg.Password) == 0 {
// Get the domain in the From address
localName := "localhost"
- fromParts := strings.Split(provider.From, `@`)
+ fromParts := strings.Split(cfg.From, `@`)
if len(fromParts) == 2 {
localName = fromParts[1]
}
// 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 {
// 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}
}
return d.DialAndSend(m)
@@ -116,19 +169,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/email/email_test.go b/alerting/provider/email/email_test.go
index a1134f27..00e398aa 100644
--- a/alerting/provider/email/email_test.go
+++ b/alerting/provider/email/email_test.go
@@ -7,61 +7,63 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{}
- if invalidProvider.IsValid() {
+ if err := invalidProvider.Validate(); err == nil {
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"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) {
- validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}
- if !validProvider.IsValid() {
+func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
+ validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- To: "to@example.com",
- Group: "",
+ Config: Config{To: "to@example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
- To: "",
- Group: "group",
+ Config: Config{To: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- From: "from@example.com",
- Password: "password",
- Host: "smtp.gmail.com",
- Port: 587,
- To: "to@example.com",
+ DefaultConfig: Config{
+ From: "from@example.com",
+ Password: "password",
+ Host: "smtp.gmail.com",
+ Port: 587,
+ To: "to@example.com",
+ },
Overrides: []Override{
{
- To: "to@example.com",
- Group: "group",
+ Config: Config{To: "to@example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -126,64 +128,104 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getToForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- To: "to@example.com",
- Overrides: nil,
+ DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
+ Overrides: nil,
},
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",
Provider: AlertProvider{
- To: "to@example.com",
- Overrides: nil,
+ DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
+ Overrides: nil,
},
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",
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{
{
- Group: "group",
- To: "to01@example.com",
+ Group: "group",
+ Config: Config{To: "to01@example.com"},
},
},
},
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",
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{
{
- Group: "group",
- To: "to01@example.com",
+ Group: "group",
+ Config: Config{To: "group-to@example.com"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ 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.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)
}
})
}
diff --git a/alerting/provider/gitea/gitea.go b/alerting/provider/gitea/gitea.go
index 489c0a86..5dd3315d 100644
--- a/alerting/provider/gitea/gitea.go
+++ b/alerting/provider/gitea/gitea.go
@@ -2,6 +2,7 @@ package gitea
import (
"crypto/tls"
+ "errors"
"fmt"
"net/http"
"net/url"
@@ -11,55 +12,56 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
-// AlertProvider is the configuration necessary for sending an alert using Discord
-type AlertProvider struct {
- 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
+var (
+ ErrRepositoryURLNotSet = errors.New("repository-url not set")
+ ErrInvalidRepositoryURL = errors.New("invalid repository-url")
+ ErrTokenNotSet = errors.New("token not set")
+)
- // 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"`
+type Config struct {
+ 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
+ Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
username string
repositoryOwner string
repositoryName string
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 (provider *AlertProvider) IsValid() bool {
- if provider.ClientConfig == nil {
- provider.ClientConfig = client.GetDefaultConfig()
+func (cfg *Config) Validate() error {
+ if len(cfg.RepositoryURL) == 0 {
+ return ErrRepositoryURLNotSet
}
-
- if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
- return false
+ if len(cfg.Token) == 0 {
+ return ErrTokenNotSet
}
// Validate format of the repository URL
- repositoryURL, err := url.Parse(provider.RepositoryURL)
+ repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil {
- return false
+ return err
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
- return false
+ return ErrInvalidRepositoryURL
}
- provider.repositoryOwner = pathParts[1]
- provider.repositoryName = pathParts[2]
-
+ if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
+ // 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{
- gitea.SetToken(provider.Token),
+ gitea.SetToken(cfg.Token),
}
-
- if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
+ if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
// add new http client for skip verify
httpClient := &http.Client{
Transport: &http.Transport{
@@ -68,34 +70,62 @@ func (provider *AlertProvider) IsValid() bool {
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
}
-
- provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
+ cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil {
- return false
+ return err
}
-
- user, _, err := provider.giteaClient.GetMyUserInfo()
+ user, _, err := cfg.giteaClient.GetMyUserInfo()
if err != nil {
- return false
+ return err
}
+ cfg.username = user.UserName
+ return nil
+}
- provider.username = user.UserName
+func (cfg *Config) Merge(override *Config) {
+ if override.ClientConfig != nil {
+ 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
+ }
+}
- return true
+// 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,
// 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 {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
- _, _, err := provider.giteaClient.CreateIssue(
- provider.repositoryOwner,
- provider.repositoryName,
+ _, _, err = cfg.giteaClient.CreateIssue(
+ cfg.repositoryOwner,
+ cfg.repositoryName,
gitea.CreateIssueOption{
Title: title,
Body: provider.buildIssueBody(ep, alert, result),
- Assignees: provider.Assignees,
+ Assignees: cfg.Assignees,
},
)
if err != nil {
@@ -103,13 +133,12 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
return nil
}
-
- issues, _, err := provider.giteaClient.ListRepoIssues(
- provider.repositoryOwner,
- provider.repositoryName,
+ issues, _, err := cfg.giteaClient.ListRepoIssues(
+ cfg.repositoryOwner,
+ cfg.repositoryName,
gitea.ListIssueOption{
State: gitea.StateOpen,
- CreatedBy: provider.username,
+ CreatedBy: cfg.username,
ListOptions: gitea.ListOptions{
Page: 100,
},
@@ -118,13 +147,12 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
-
for _, issue := range issues {
if issue.Title == title {
stateClosed := gitea.StateClosed
- _, _, err = provider.giteaClient.EditIssue(
- provider.repositoryOwner,
- provider.repositoryName,
+ _, _, err = cfg.giteaClient.EditIssue(
+ cfg.repositoryOwner,
+ cfg.repositoryName,
issue.ID,
gitea.EditIssueOption{
State: &stateClosed,
@@ -165,3 +193,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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
+}
diff --git a/alerting/provider/gitea/gitea_test.go b/alerting/provider/gitea/gitea_test.go
index bac9dacd..b2c601ca 100644
--- a/alerting/provider/gitea/gitea_test.go
+++ b/alerting/provider/gitea/gitea_test.go
@@ -12,42 +12,46 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
- Name string
- Provider AlertProvider
- Expected bool
+ Name string
+ Provider AlertProvider
+ ExpectedError bool
}{
{
- Name: "invalid",
- Provider: AlertProvider{RepositoryURL: "", Token: ""},
- Expected: false,
+ Name: "invalid",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
+ ExpectedError: true,
},
{
- Name: "invalid-token",
- Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
- Expected: false,
+ Name: "invalid-token",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
+ ExpectedError: true,
},
{
- Name: "missing-repository-name",
- Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"},
- Expected: false,
+ Name: "missing-repository-name",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
+ ExpectedError: true,
},
{
- Name: "enterprise-client",
- Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"},
- Expected: false,
+ Name: "enterprise-client",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
+ ExpectedError: false,
},
{
- Name: "invalid-url",
- Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"},
- Expected: false,
+ Name: "invalid-url",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
+ ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
- if scenario.Provider.IsValid() != scenario.Expected {
- t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
+ err := scenario.Provider.Validate()
+ 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",
- 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},
Resolved: false,
ExpectedError: true,
},
{
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},
Resolved: true,
ExpectedError: true,
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
}
for _, scenario := range scenarios {
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})
- err := scenario.Provider.Send(
+ err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
@@ -167,3 +175,55 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/github/github.go b/alerting/provider/github/github.go
index c425d669..b89131a7 100644
--- a/alerting/provider/github/github.go
+++ b/alerting/provider/github/github.go
@@ -2,6 +2,7 @@ package github
import (
"context"
+ "errors"
"fmt"
"net/url"
"strings"
@@ -11,69 +12,104 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github"
"golang.org/x/oauth2"
+ "gopkg.in/yaml.v3"
)
-// AlertProvider is the configuration necessary for sending an alert using Discord
-type AlertProvider struct {
+var (
+ 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
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
repositoryOwner string
repositoryName string
githubClient *github.Client
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
- return false
+func (cfg *Config) Validate() error {
+ if len(cfg.RepositoryURL) == 0 {
+ return ErrRepositoryURLNotSet
+ }
+ if len(cfg.Token) == 0 {
+ return ErrTokenNotSet
}
// Validate format of the repository URL
- repositoryURL, err := url.Parse(provider.RepositoryURL)
+ repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil {
- return false
+ return err
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
- return false
+ return ErrInvalidRepositoryURL
}
- provider.repositoryOwner = pathParts[1]
- provider.repositoryName = pathParts[2]
+ if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
+ // 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
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
- AccessToken: provider.Token,
+ AccessToken: cfg.Token,
}))
// Create GitHub client
if baseURL == "https://github.com" {
- provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
+ cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else {
- provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
+ cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
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
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- user, _, err := provider.githubClient.Users.Get(ctx, "")
+ user, _, err := cfg.githubClient.Users.Get(ctx, "")
if err != nil {
- return false
+ return fmt.Errorf("failed to retrieve GitHub user: %w", err)
}
- provider.username = *user.Login
- return true
+ cfg.username = *user.Login
+ 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,
// 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 {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
title := "alert(gatus): " + ep.DisplayName()
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),
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)
}
} 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",
- Creator: provider.username,
+ Creator: cfg.username,
ListOptions: github.ListOptions{PerPage: 100},
})
if err != nil {
@@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
for _, issue := range issues {
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"),
})
if err != nil {
@@ -130,3 +166,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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
+}
diff --git a/alerting/provider/github/github_test.go b/alerting/provider/github/github_test.go
index e69a3197..a701c8ba 100644
--- a/alerting/provider/github/github_test.go
+++ b/alerting/provider/github/github_test.go
@@ -12,42 +12,46 @@ import (
"github.com/google/go-github/v48/github"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
- Name string
- Provider AlertProvider
- Expected bool
+ Name string
+ Provider AlertProvider
+ ExpectedError bool
}{
{
- Name: "invalid",
- Provider: AlertProvider{RepositoryURL: "", Token: ""},
- Expected: false,
+ Name: "invalid",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
+ ExpectedError: true,
},
{
- Name: "invalid-token",
- Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
- Expected: false,
+ Name: "invalid-token",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
+ ExpectedError: true,
},
{
- Name: "missing-repository-name",
- Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"},
- Expected: false,
+ Name: "missing-repository-name",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
+ ExpectedError: true,
},
{
- Name: "enterprise-client",
- Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"},
- Expected: false,
+ Name: "enterprise-client",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
+ ExpectedError: true,
},
{
- Name: "invalid-url",
- Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"},
- Expected: false,
+ Name: "invalid-url",
+ Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
+ ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
- if scenario.Provider.IsValid() != scenario.Expected {
- t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
+ err := scenario.Provider.Validate()
+ 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",
- 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},
Resolved: false,
ExpectedError: true,
},
{
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},
Resolved: true,
ExpectedError: true,
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
}
for _, scenario := range scenarios {
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})
- err := scenario.Provider.Send(
+ err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
@@ -167,3 +175,47 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/gitlab/gitlab.go b/alerting/provider/gitlab/gitlab.go
index d9d5676a..0ee65d2f 100644
--- a/alerting/provider/gitlab/gitlab.go
+++ b/alerting/provider/gitlab/gitlab.go
@@ -13,55 +13,97 @@ import (
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"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
type AlertProvider struct {
- WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
- AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
+ 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"`
-
- // 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
-func (provider *AlertProvider) IsValid() bool {
- if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 {
- return false
- }
- // Validate format of the repository URL
- _, err := url.Parse(provider.WebhookURL)
- if err != nil {
- return false
- }
- return true
+// 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,
// 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 {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString()
}
- buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
- request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
+ buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
- 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)
if err != nil {
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.
}
-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
-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{
- 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),
- Service: provider.service(ep),
- MonitoringTool: provider.monitoringTool(),
+ Service: service,
+ MonitoringTool: cfg.MonitoringTool,
Hosts: ep.URL,
- GitlabEnvironmentName: provider.EnvironmentName,
- Severity: provider.Severity,
+ GitlabEnvironmentName: cfg.EnvironmentName,
+ Severity: cfg.Severity,
Fingerprint: alert.ResolveKey,
}
if resolved {
@@ -148,3 +180,25 @@ func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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
+}
diff --git a/alerting/provider/gitlab/gitlab_test.go b/alerting/provider/gitlab/gitlab_test.go
index 290b2e6e..2421284e 100644
--- a/alerting/provider/gitlab/gitlab_test.go
+++ b/alerting/provider/gitlab/gitlab_test.go
@@ -11,37 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
- Name string
- Provider AlertProvider
- Expected bool
+ Name string
+ Provider AlertProvider
+ ExpectedError bool
}{
{
- Name: "invalid",
- Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
- Expected: false,
+ Name: "invalid",
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
+ ExpectedError: true,
},
{
- Name: "missing-webhook-url",
- Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
- Expected: false,
+ Name: "missing-webhook-url",
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
+ ExpectedError: true,
},
{
- Name: "missing-authorization-key",
- Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
- Expected: false,
+ Name: "missing-authorization-key",
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
+ ExpectedError: true,
},
{
- Name: "invalid-url",
- Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
- Expected: false,
+ Name: "invalid-url",
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
+ ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
- if scenario.Provider.IsValid() != scenario.Expected {
- t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
+ err := scenario.Provider.Validate()
+ 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",
- 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},
Resolved: false,
ExpectedError: false,
@@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: true,
ExpectedError: false,
@@ -116,21 +120,26 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
{
Name: "triggered",
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},
- 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",
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},
- 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 {
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(
+ cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -156,3 +165,59 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/googlechat/googlechat.go b/alerting/provider/googlechat/googlechat.go
index fb2f8f97..aeeeec93 100644
--- a/alerting/provider/googlechat/googlechat.go
+++ b/alerting/provider/googlechat/googlechat.go
@@ -3,6 +3,7 @@ package googlechat
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,14 +11,38 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"`
+ 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
type AlertProvider struct {
- WebhookURL string `yaml:"webhook-url"`
-
- // ClientConfig is the configuration of the client used to communicate with the provider's target
- ClientConfig *client.Config `yaml:"client,omitempty"`
+ 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"`
@@ -28,36 +53,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- WebhookURL string `yaml:"webhook-url"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- if provider.ClientConfig == nil {
- provider.ClientConfig = client.GetDefaultConfig()
- }
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
- return false
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return len(provider.WebhookURL) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
buffer := bytes.NewBuffer(provider.buildRequestBody(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 {
return err
}
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 {
return err
}
@@ -185,19 +211,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON
}
-// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
-func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
- if provider.Overrides != nil {
- for _, override := range provider.Overrides {
- if group == override.Group {
- return override.WebhookURL
- }
- }
- }
- return provider.WebhookURL
-}
-
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// 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
+}
diff --git a/alerting/provider/googlechat/googlechat_test.go b/alerting/provider/googlechat/googlechat_test.go
index 78e5e6ea..5dbd6cb1 100644
--- a/alerting/provider/googlechat/googlechat_test.go
+++ b/alerting/provider/googlechat/googlechat_test.go
@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{WebhookURL: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{WebhookURL: "http://example.com"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "",
- Group: "group",
+ Config: Config{WebhookURL: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "group",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -213,64 +213,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
+ t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
}
})
}
diff --git a/alerting/provider/gotify/gotify.go b/alerting/provider/gotify/gotify.go
index 7b1f7a61..1d492910 100644
--- a/alerting/provider/gotify/gotify.go
+++ b/alerting/provider/gotify/gotify.go
@@ -3,6 +3,7 @@ package gotify
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,40 +11,72 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
const 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
type AlertProvider struct {
- // ServerURL is the URL of the Gotify server
- 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
+ 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"`
-
- // 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
-func (provider *AlertProvider) IsValid() bool {
- if provider.Priority == 0 {
- provider.Priority = DefaultPriority
- }
- return len(provider.ServerURL) > 0 && len(provider.Token) > 0
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
- request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
+ request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, buffer)
if err != nil {
return err
}
@@ -67,7 +100,7 @@ type Body struct {
}
// 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
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)
@@ -89,13 +122,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
}
message += formattedConditionResults
title := "Gatus: " + ep.DisplayName()
- if provider.Title != "" {
- title = provider.Title
+ if cfg.Title != "" {
+ title = cfg.Title
}
bodyAsJSON, _ := json.Marshal(Body{
Message: message,
Title: title,
- Priority: provider.Priority,
+ Priority: cfg.Priority,
})
return bodyAsJSON
}
@@ -104,3 +137,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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
+}
diff --git a/alerting/provider/gotify/gotify_test.go b/alerting/provider/gotify/gotify_test.go
index af644f48..9db61845 100644
--- a/alerting/provider/gotify/gotify_test.go
+++ b/alerting/provider/gotify/gotify_test.go
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
-func TestAlertProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
@@ -17,29 +17,29 @@ func TestAlertProvider_IsValid(t *testing.T) {
}{
{
name: "valid",
- provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
+ provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true,
},
{
name: "invalid-server-url",
- provider: AlertProvider{ServerURL: "", Token: "faketoken"},
+ provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
expected: false,
},
{
name: "invalid-app-token",
- provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
+ provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
expected: false,
},
{
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,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
- if scenario.provider.IsValid() != scenario.expected {
- t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
+ if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
+ t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
}
})
}
@@ -60,21 +60,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
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),
},
{
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},
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),
},
{
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},
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),
@@ -83,6 +83,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: endpointName},
&scenario.Alert,
&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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/jetbrainsspace/jetbrainsspace.go b/alerting/provider/jetbrainsspace/jetbrainsspace.go
index aa9d928f..9e5fe7c5 100644
--- a/alerting/provider/jetbrainsspace/jetbrainsspace.go
+++ b/alerting/provider/jetbrainsspace/jetbrainsspace.go
@@ -3,6 +3,7 @@ package jetbrainsspace
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,13 +11,50 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
+var (
+ 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
type AlertProvider struct {
- Project string `yaml:"project"` // JetBrains Space Project name
- ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
- Token string `yaml:"token"` // JetBrains Space Bearer Token
+ 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"`
@@ -27,34 +65,38 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- ChannelID string `yaml:"channel-id"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
- if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 {
- return false
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
}
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
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))
- url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ 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)
if err != nil {
return err
}
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)
if err != nil {
return err
@@ -103,9 +145,9 @@ type Icon struct {
}
// 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{
- Channel: "id:" + provider.getChannelIDForGroup(ep.Group),
+ Channel: "id:" + cfg.ChannelID,
Content: Content{
ClassName: "ChatMessage.Block",
Sections: []Section{{
@@ -144,19 +186,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/jetbrainsspace/jetbrainsspace_test.go b/alerting/provider/jetbrainsspace/jetbrainsspace_test.go
index c9fbcd9c..57f834f6 100644
--- a/alerting/provider/jetbrainsspace/jetbrainsspace_test.go
+++ b/alerting/provider/jetbrainsspace/jetbrainsspace_test.go
@@ -11,54 +11,56 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{Project: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
- Project: "foobar",
+ DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
- ChannelID: "http://example.com",
- Group: "",
+ Config: Config{ChannelID: "http://example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
- Project: "foobar",
+ DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
- ChannelID: "",
- Group: "group",
+ Config: Config{ChannelID: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- Project: "foo",
- ChannelID: "bar",
- Token: "baz",
+ DefaultConfig: Config{
+ Project: "foo",
+ ChannelID: "bar",
+ Token: "baz",
+ },
Overrides: []Override{
{
- ChannelID: "foobar",
- Group: "group",
+ Config: Config{ChannelID: "foobar"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -77,7 +79,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -87,7 +89,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -97,7 +99,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -107,7 +109,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -153,40 +155,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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 {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -217,62 +220,98 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getChannelIDForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- ChannelID: "bar",
+ DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
+ Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: "bar",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- ChannelID: "bar",
+ DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
+ Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: "bar",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- ChannelID: "bar",
+ DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{
{
- Group: "group",
- ChannelID: "foobar",
+ Group: "group",
+ Config: Config{ChannelID: "group-channel"},
},
},
},
InputGroup: "",
- ExpectedOutput: "bar",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- ChannelID: "bar",
+ DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{
{
- Group: "group",
- ChannelID: "foobar",
+ Group: "group",
+ Config: Config{ChannelID: "group-channel"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ 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.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)
}
})
}
diff --git a/alerting/provider/matrix/matrix.go b/alerting/provider/matrix/matrix.go
index 4bc8754d..e735d148 100644
--- a/alerting/provider/matrix/matrix.go
+++ b/alerting/provider/matrix/matrix.go
@@ -3,6 +3,7 @@ package matrix
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"math/rand"
@@ -13,29 +14,18 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
-// AlertProvider is the configuration necessary for sending an alert using Matrix
-type AlertProvider struct {
- ProviderConfig `yaml:",inline"`
-
- // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
- DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
-
- // Overrides is a list of Override that may be prioritized over the default configuration
- Overrides []Override `yaml:"overrides,omitempty"`
-}
-
-// Override is a case under which the default integration is overridden
-type Override struct {
- Group string `yaml:"group"`
-
- ProviderConfig `yaml:",inline"`
-}
-
const defaultServerURL = "https://matrix-client.matrix.org"
-type ProviderConfig struct {
+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"`
@@ -46,36 +36,78 @@ type ProviderConfig struct {
InternalRoomID string `yaml:"internal-room-id"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+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
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
- return false
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
- config := provider.getConfigForGroup(ep.Group)
- if config.ServerURL == "" {
- config.ServerURL = defaultServerURL
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
}
+ buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
// The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24)
request, err := http.NewRequest(
http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
- config.ServerURL,
- url.PathEscape(config.InternalRoomID),
+ cfg.ServerURL,
+ url.PathEscape(cfg.InternalRoomID),
txnId,
- url.QueryEscape(config.AccessToken),
+ url.QueryEscape(cfg.AccessToken),
),
buffer,
)
@@ -167,18 +199,6 @@ func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *end
return fmt.Sprintf("
%s
%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 {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -194,3 +214,34 @@ func randStringBytes(n int) string {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/matrix/matrix_test.go b/alerting/provider/matrix/matrix_test.go
index 923cae55..561ae16c 100644
--- a/alerting/provider/matrix/matrix_test.go
+++ b/alerting/provider/matrix/matrix_test.go
@@ -11,75 +11,75 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
AccessToken: "",
InternalRoomID: "",
},
}
- if invalidProvider.IsValid() {
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
- if !validProvider.IsValid() {
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithHomeserver := AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
- if !validProviderWithHomeserver.IsValid() {
+ if err := validProviderWithHomeserver.Validate(); err != nil {
t.Error("provider with homeserver should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Group: "",
- ProviderConfig: ProviderConfig{
+ Config: Config{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Group: "group",
- ProviderConfig: ProviderConfig{
+ Config: Config{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
- ProviderConfig: ProviderConfig{
+ Config: Config{
ServerURL: "https://example.com",
AccessToken: "1",
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")
}
}
@@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
ExpectedError bool
}{
{
- Name: "triggered",
+ Name: "triggered-with-bad-config",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
+ ExpectedError: 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,
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ 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 {
@@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
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) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput ProviderConfig
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: ProviderConfig{
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: ProviderConfig{
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{
{
Group: "group",
- ProviderConfig: ProviderConfig{
- ServerURL: "https://example01.com",
+ Config: Config{
+ ServerURL: "https://group-example.com",
AccessToken: "12",
- InternalRoomID: "!a:example01.com",
+ InternalRoomID: "!a:group-example.com",
},
},
},
},
InputGroup: "",
- ExpectedOutput: ProviderConfig{
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- ProviderConfig: ProviderConfig{
+ DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{
{
Group: "group",
- ProviderConfig: ProviderConfig{
- ServerURL: "https://example01.com",
+ Config: Config{
+ 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",
InternalRoomID: "!a:example01.com",
},
@@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
},
},
InputGroup: "group",
- ExpectedOutput: ProviderConfig{
- ServerURL: "https://example01.com",
- AccessToken: "12",
- InternalRoomID: "!a:example01.com",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
+ ExpectedOutput: Config{
+ ServerURL: "https://alert-example.com",
+ AccessToken: "123",
+ InternalRoomID: "!a:alert-example.com",
},
},
}
- for _, tt := range tests {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ 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)
}
})
}
diff --git a/alerting/provider/mattermost/mattermost.go b/alerting/provider/mattermost/mattermost.go
index 23899e28..564eb990 100644
--- a/alerting/provider/mattermost/mattermost.go
+++ b/alerting/provider/mattermost/mattermost.go
@@ -3,6 +3,7 @@ package mattermost
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,17 +11,42 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
+var (
+ ErrWebhookURLNotSet = errors.New("webhook URL not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"`
+ 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
type AlertProvider struct {
- WebhookURL string `yaml:"webhook-url"`
-
- // 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"`
+ 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"`
@@ -31,36 +57,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- WebhookURL string `yaml:"webhook-url"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- if provider.ClientConfig == nil {
- provider.ClientConfig = client.GetDefaultConfig()
- }
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
if provider.Overrides != nil {
registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
- return false
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return len(provider.WebhookURL) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
- request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ 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 {
return err
}
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 {
return err
}
@@ -96,7 +123,7 @@ type Field struct {
}
// 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
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)
@@ -122,7 +149,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription
}
body := Body{
- Channel: provider.Channel,
+ Channel: cfg.Channel,
Text: "",
Username: "gatus",
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
}
-// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
-func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
- if provider.Overrides != nil {
- for _, override := range provider.Overrides {
- if group == override.Group {
- return override.WebhookURL
- }
- }
- }
- return provider.WebhookURL
-}
-
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// 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
+}
diff --git a/alerting/provider/mattermost/mattermost_test.go b/alerting/provider/mattermost/mattermost_test.go
index b476bfc8..0ce253ab 100644
--- a/alerting/provider/mattermost/mattermost_test.go
+++ b/alerting/provider/mattermost/mattermost_test.go
@@ -11,54 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{WebhookURL: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{WebhookURL: "http://example.com"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "",
},
},
}
-
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
-
providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{
{
-
- WebhookURL: "",
- Group: "group",
+ Config: Config{WebhookURL: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideWebHookUrl.IsValid() {
+ if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
t.Error("provider WebHookURL shouldn't have been valid")
}
-
providerWithValidOverride := AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "group",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -168,6 +164,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -198,64 +195,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
+ t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
}
})
}
diff --git a/alerting/provider/messagebird/messagebird.go b/alerting/provider/messagebird/messagebird.go
index 235a0a60..fe02d394 100644
--- a/alerting/provider/messagebird/messagebird.go
+++ b/alerting/provider/messagebird/messagebird.go
@@ -3,6 +3,7 @@ package messagebird
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,37 +11,75 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
-const (
- restAPIURL = "https://rest.messagebird.com/messages"
+const 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 AlertProvider struct {
+type Config struct {
AccessKey string `yaml:"access-key"`
Originator string `yaml:"originator"`
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 *alert.Alert `yaml:"default-alert,omitempty"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
// 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 {
- 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)
if err != nil {
return err
}
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)
if err != nil {
return err
@@ -60,7 +99,7 @@ type Body struct {
}
// 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
if resolved {
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())
}
body, _ := json.Marshal(Body{
- Originator: provider.Originator,
- Recipients: provider.Recipients,
+ Originator: cfg.Originator,
+ Recipients: cfg.Recipients,
Body: message,
})
return body
@@ -79,3 +118,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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
+}
diff --git a/alerting/provider/messagebird/messagebird_test.go b/alerting/provider/messagebird/messagebird_test.go
index 22c9a4d6..29b5191e 100644
--- a/alerting/provider/messagebird/messagebird_test.go
+++ b/alerting/provider/messagebird/messagebird_test.go
@@ -13,15 +13,17 @@ import (
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
- if invalidProvider.IsValid() {
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
- AccessKey: "1",
- Originator: "1",
- Recipients: "1",
+ DefaultConfig: Config{
+ AccessKey: "1",
+ Originator: "1",
+ Recipients: "1",
+ },
}
- if !validProvider.IsValid() {
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
Resolved: false,
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
},
{
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},
Resolved: true,
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 {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
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())
}
})
@@ -160,3 +163,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/ntfy/ntfy.go b/alerting/provider/ntfy/ntfy.go
index 6deba1aa..9f875129 100644
--- a/alerting/provider/ntfy/ntfy.go
+++ b/alerting/provider/ntfy/ntfy.go
@@ -3,6 +3,7 @@ package ntfy
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -12,6 +13,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
const (
@@ -20,8 +22,14 @@ const (
TokenPrefix = "tk_"
)
-// AlertProvider is the configuration necessary for sending an alert using Slack
-type AlertProvider struct {
+var (
+ 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"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
@@ -30,6 +38,57 @@ type AlertProvider struct {
Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,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 *alert.Alert `yaml:"default-alert,omitempty"`
@@ -40,66 +99,54 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- Topic string `yaml:"topic"`
- URL string `yaml:"url"`
- Priority int `yaml:"priority"`
- Token string `yaml:"token"`
- Email string `yaml:"email"`
- Click string `yaml:"click"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- 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)
- }
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if len(override.Group) == 0 {
- return false
+ return ErrDuplicateGroupOverride
}
if _, ok := registeredGroups[override.Group]; ok {
- return false
+ return ErrDuplicateGroupOverride
}
if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {
- return false
+ return ErrDuplicateGroupOverride
}
if override.Priority < 0 || override.Priority >= 6 {
- return false
+ return ErrDuplicateGroupOverride
}
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
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- override := provider.getGroupOverride(ep.Group)
- buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved, override))
- url := provider.getURL(override)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
+ url := cfg.URL
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
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)
}
- if provider.DisableFirebase {
+ if cfg.DisableFirebase {
request.Header.Set("Firebase", "no")
}
- if provider.DisableCache {
+ if cfg.DisableCache {
request.Header.Set("Cache", "no")
}
response, err := client.GetHTTPClient(nil).Do(request)
@@ -125,7 +172,7 @@ type Body struct {
}
// 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
if resolved {
tag = "white_check_mark"
@@ -148,13 +195,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
}
message += formattedConditionResults
body, _ := json.Marshal(Body{
- Topic: provider.getTopic(override),
+ Topic: cfg.Topic,
Title: "Gatus: " + ep.DisplayName(),
Message: message,
Tags: []string{tag},
- Priority: provider.getPriority(override),
- Email: provider.getEmail(override),
- Click: provider.getClick(override),
+ Priority: cfg.Priority,
+ Email: cfg.Email,
+ Click: cfg.Click,
})
return body
}
@@ -164,55 +211,33 @@ func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
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 {
for _, override := range provider.Overrides {
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 {
- if override != nil && len(override.Topic) > 0 {
- return override.Topic
- }
- 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
+// 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
}
diff --git a/alerting/provider/ntfy/ntfy_test.go b/alerting/provider/ntfy/ntfy_test.go
index 304240d8..16ef6cb3 100644
--- a/alerting/provider/ntfy/ntfy_test.go
+++ b/alerting/provider/ntfy/ntfy_test.go
@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
+func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
@@ -19,74 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
}{
{
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,
},
{
name: "no-url-should-use-default-value",
- provider: AlertProvider{Topic: "example", Priority: 1},
+ provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
expected: true,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
name: "no-override-group-name",
- provider: AlertProvider{Topic: "example", Overrides: []Override{Override{}}},
+ provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{}}},
expected: false,
},
{
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,
},
{
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,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
- if scenario.provider.IsValid() != scenario.expected {
- t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
+ err := scenario.provider.Validate()
+ 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
Alert alert.Alert
Resolved bool
- Override *Override
ExpectedBody string
}{
{
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},
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}`,
},
{
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},
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}`,
},
{
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},
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"}`,
},
{
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},
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"}`,
},
{
- Name: "override",
- Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"},
+ Name: "group-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},
Resolved: false,
- Override: &Override{Group: "g", Topic: "override-topic", 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"}`,
+ 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"}`,
+ },
+ {
+ 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 {
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(
+ cfg,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -156,7 +166,6 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
},
scenario.Resolved,
- scenario.Override,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
@@ -182,7 +191,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
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},
Resolved: false,
Group: "",
@@ -193,7 +202,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
Group: "",
@@ -205,7 +214,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
Group: "",
@@ -217,7 +226,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
Group: "",
@@ -229,7 +238,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
Group: "",
@@ -242,7 +251,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
Group: "test-group",
@@ -273,7 +282,7 @@ func TestAlertProvider_Send(t *testing.T) {
// Close the server when test finishes
defer server.Close()
- scenario.Provider.URL = server.URL
+ scenario.Provider.DefaultConfig.URL = server.URL
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
&scenario.Alert,
@@ -288,8 +297,118 @@ func TestAlertProvider_Send(t *testing.T) {
if err != nil {
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)
+ }
+ })
+ }
}
diff --git a/alerting/provider/opsgenie/opsgenie.go b/alerting/provider/opsgenie/opsgenie.go
index a0b4167f..bd968427 100644
--- a/alerting/provider/opsgenie/opsgenie.go
+++ b/alerting/provider/opsgenie/opsgenie.go
@@ -3,6 +3,7 @@ package opsgenie
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -12,13 +13,18 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
const (
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 string `yaml:"api-key"`
@@ -46,26 +52,74 @@ type AlertProvider struct {
//
// default: []
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 *alert.Alert `yaml:"default-alert,omitempty"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- return len(provider.APIKey) > 0
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
//
// Relevant: https://docs.opsgenie.com/docs/alert-api
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 {
return err
}
if resolved {
- err = provider.closeAlert(ep, alert)
+ err = provider.closeAlert(cfg, ep, alert)
if err != nil {
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
alert.ResolveKey = ""
} else {
- alert.ResolveKey = provider.alias(buildKey(ep))
+ alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
}
}
return nil
}
-func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- payload := provider.buildCreateRequestBody(ep, alert, result, resolved)
- return provider.sendRequest(restAPI, http.MethodPost, payload)
+func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
+ 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)
- url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias"
- return provider.sendRequest(url, http.MethodPost, payload)
+ url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
+ 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)
if err != nil {
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
}
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)
if err != nil {
return err
@@ -115,7 +169,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
@@ -158,11 +212,11 @@ func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, ale
return alertCreateRequest{
Message: message,
Description: description,
- Source: provider.source(),
- Priority: provider.priority(),
- Alias: provider.alias(key),
- Entity: provider.entity(key),
- Tags: provider.Tags,
+ Source: cfg.Source,
+ Priority: cfg.Priority,
+ Alias: cfg.AliasPrefix + key,
+ Entity: cfg.EntityPrefix + key,
+ Tags: cfg.Tags,
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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 {
name := toKebabCase(ep.Name)
if ep.Group == "" {
diff --git a/alerting/provider/opsgenie/opsgenie_test.go b/alerting/provider/opsgenie/opsgenie_test.go
index 746eb387..11064c5c 100644
--- a/alerting/provider/opsgenie/opsgenie_test.go
+++ b/alerting/provider/opsgenie/opsgenie_test.go
@@ -11,13 +11,13 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{APIKey: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false,
ExpectedError: false,
@@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
@@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: false,
@@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
@@ -74,7 +74,6 @@ func TestAlertProvider_Send(t *testing.T) {
}),
},
}
-
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
@@ -113,7 +112,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}{
{
Name: "missing all params (unresolved)",
- Provider: &AlertProvider{},
+ Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
@@ -131,7 +130,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
},
{
Name: "missing all params (resolved)",
- Provider: &AlertProvider{},
+ Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
@@ -149,7 +148,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
},
{
Name: "with default options (unresolved)",
- Provider: &AlertProvider{},
+ Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 3,
@@ -184,11 +183,13 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{
Name: "with custom options (resolved)",
Provider: &AlertProvider{
- Priority: "P5",
- EntityPrefix: "oompa-",
- AliasPrefix: "loompa-",
- Source: "gatus-hc",
- Tags: []string{"do-ba-dee-doo"},
+ DefaultConfig: Config{
+ Priority: "P5",
+ EntityPrefix: "oompa-",
+ AliasPrefix: "loompa-",
+ Source: "gatus-hc",
+ Tags: []string{"do-ba-dee-doo"},
+ },
},
Alert: &alert.Alert{
Description: &description,
@@ -220,7 +221,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{
Name: "with default options and details (unresolved)",
Provider: &AlertProvider{
- Tags: []string{"foo"},
+ DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
},
Alert: &alert.Alert{
Description: &description,
@@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
for _, scenario := range scenarios {
actual := scenario
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) {
- t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
+ _ = scenario.Provider.Validate()
+ 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 {
actual := scenario
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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/pagerduty/pagerduty.go b/alerting/provider/pagerduty/pagerduty.go
index 8eacf860..3a563f93 100644
--- a/alerting/provider/pagerduty/pagerduty.go
+++ b/alerting/provider/pagerduty/pagerduty.go
@@ -3,6 +3,7 @@ package pagerduty
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -11,15 +12,38 @@ import (
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
+ "gopkg.in/yaml.v3"
)
const (
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
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 *alert.Alert `yaml:"default-alert,omitempty"`
@@ -30,30 +54,34 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- IntegrationKey string `yaml:"integration-key"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
- if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
- return false
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
// 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
//
// 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 {
- 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)
if err != nil {
return err
@@ -100,7 +128,7 @@ type Payload struct {
}
// 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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@@ -112,7 +140,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
resolveKey = ""
}
body, _ := json.Marshal(Body{
- RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
+ RoutingKey: cfg.IntegrationKey,
DedupKey: resolveKey,
EventAction: eventAction,
Payload: Payload{
@@ -124,23 +152,42 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
+
type pagerDutyResponsePayload struct {
Status string `json:"status"`
Message string `json:"message"`
diff --git a/alerting/provider/pagerduty/pagerduty_test.go b/alerting/provider/pagerduty/pagerduty_test.go
index 23d0b410..2e66cec3 100644
--- a/alerting/provider/pagerduty/pagerduty_test.go
+++ b/alerting/provider/pagerduty/pagerduty_test.go
@@ -11,50 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{IntegrationKey: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
+ DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
- IntegrationKey: "00000000000000000000000000000000",
- Group: "",
+ Config: Config{IntegrationKey: "00000000000000000000000000000002"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
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{
+ DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
- IntegrationKey: "00000000000000000000000000000000",
- Group: "group",
+ Config: Config{IntegrationKey: "00000000000000000000000000000002"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
- t.Error("provider should've been valid")
+ if err := providerWithValidOverride.Validate(); err != nil {
+ t.Error("provider should've been valid, got error:", err.Error())
}
}
@@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
+ Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description},
Resolved: false,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
{
Name: "resolved",
- Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
+ Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true,
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 {
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 {
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) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == 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")
}
}
+
+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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go
index 5bccc8a4..cee8103d 100644
--- a/alerting/provider/provider.go
+++ b/alerting/provider/provider.go
@@ -29,18 +29,26 @@ import (
// AlertProvider is the interface that each provider should implement
type AlertProvider interface {
- // IsValid returns whether the provider's configuration is valid
- IsValid() bool
+ // Validate the provider's configuration
+ 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() *alert.Alert
- // Send an alert using the provider
- Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
+ // ValidateOverrides validates the alert's provider override and, if present, the group override
+ ValidateOverrides(group string, alert *alert.Alert) error
}
-// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
-func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
+type Config[T any] interface {
+ 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 {
return
}
@@ -62,14 +70,14 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
}
var (
- // Validate interface implementation on compile
+ // Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
+ _ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
- _ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
@@ -85,4 +93,28 @@ var (
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.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)
)
diff --git a/alerting/provider/provider_test.go b/alerting/provider/provider_test.go
index bb804e45..fa1de566 100644
--- a/alerting/provider/provider_test.go
+++ b/alerting/provider/provider_test.go
@@ -126,7 +126,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
- ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
+ MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil {
if scenario.EndpointAlert != nil {
t.Fail()
diff --git a/alerting/provider/pushover/pushover.go b/alerting/provider/pushover/pushover.go
index 7cfa7715..0b5c3141 100644
--- a/alerting/provider/pushover/pushover.go
+++ b/alerting/provider/pushover/pushover.go
@@ -3,6 +3,7 @@ package pushover
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,6 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
const (
@@ -17,8 +19,13 @@ const (
defaultPriority = 0
)
-// AlertProvider is the configuration necessary for sending an alert using Pushover
-type AlertProvider struct {
+var (
+ 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
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"`
@@ -41,26 +48,69 @@ type AlertProvider struct {
// Sound of the messages (see: https://pushover.net/api#sounds)
// default: "" (pushover)
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 *alert.Alert `yaml:"default-alert,omitempty"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- if provider.Priority == 0 {
- 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
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api
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)
if err != nil {
return err
@@ -88,38 +138,51 @@ type Body struct {
}
// 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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
+ priority := cfg.Priority
+ if resolved {
+ priority = cfg.ResolvedPriority
+ }
body, _ := json.Marshal(Body{
- Token: provider.ApplicationToken,
- User: provider.UserKey,
- Title: provider.Title,
+ Token: cfg.ApplicationToken,
+ User: cfg.UserKey,
+ Title: cfg.Title,
Message: message,
- Priority: provider.priority(resolved),
- Sound: provider.Sound,
+ Priority: priority,
+ Sound: cfg.Sound,
})
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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
+}
diff --git a/alerting/provider/pushover/pushover_test.go b/alerting/provider/pushover/pushover_test.go
index bf934f65..4b797ace 100644
--- a/alerting/provider/pushover/pushover_test.go
+++ b/alerting/provider/pushover/pushover_test.go
@@ -12,31 +12,38 @@ import (
)
func TestPushoverAlertProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{}
- if invalidProvider.IsValid() {
- t.Error("provider shouldn't have been valid")
- }
- validProvider := AlertProvider{
- ApplicationToken: "aTokenWithLengthOf30characters",
- UserKey: "aTokenWithLengthOf30characters",
- Title: "Gatus Notification",
- Priority: 1,
- ResolvedPriority: 1,
- }
- if !validProvider.IsValid() {
- t.Error("provider should've been valid")
- }
-}
-
-func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
- invalidProvider := AlertProvider{
- ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
- UserKey: "aTokenWithLengthOfMoreThan30characters",
- Priority: 5,
- }
- if invalidProvider.IsValid() {
- t.Error("provider should've been invalid")
- }
+ t.Run("empty-invalid-provider", func(t *testing.T) {
+ invalidProvider := AlertProvider{}
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+ })
+ t.Run("valid-provider", func(t *testing.T) {
+ validProvider := AlertProvider{
+ DefaultConfig: Config{
+ ApplicationToken: "aTokenWithLengthOf30characters",
+ UserKey: "aTokenWithLengthOf30characters",
+ Title: "Gatus Notification",
+ Priority: 1,
+ ResolvedPriority: 1,
+ },
+ }
+ if err := validProvider.Validate(); err != nil {
+ t.Error("provider should've been valid")
+ }
+ })
+ t.Run("invalid-provider", func(t *testing.T) {
+ invalidProvider := AlertProvider{
+ DefaultConfig: Config{
+ ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
+ UserKey: "aTokenWithLengthOfMoreThan30characters",
+ Priority: 5,
+ },
+ }
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider should've been invalid")
+ }
+ })
}
func TestAlertProvider_Send(t *testing.T) {
@@ -53,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -63,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -73,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -129,28 +136,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
},
{
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},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
},
{
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},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":0}",
},
{
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},
Resolved: true,
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 {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -188,3 +196,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/slack/slack.go b/alerting/provider/slack/slack.go
index 46d327b2..e8f5376f 100644
--- a/alerting/provider/slack/slack.go
+++ b/alerting/provider/slack/slack.go
@@ -3,6 +3,7 @@ package slack
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,41 +11,70 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"` // 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
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 *alert.Alert `yaml:"default-alert,omitempty"`
+
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- WebhookURL string `yaml:"webhook-url"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
- if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
- return false
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return len(provider.WebhookURL) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
buffer := bytes.NewBuffer(provider.buildRequestBody(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 {
return err
}
@@ -126,19 +156,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON
}
-// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
-func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
- if provider.Overrides != nil {
- for _, override := range provider.Overrides {
- if group == override.Group {
- return override.WebhookURL
- }
- }
- }
- return provider.WebhookURL
-}
-
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// 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
+}
diff --git a/alerting/provider/slack/slack_test.go b/alerting/provider/slack/slack_test.go
index f3e95fcd..aa9bd1d3 100644
--- a/alerting/provider/slack/slack_test.go
+++ b/alerting/provider/slack/slack_test.go
@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{WebhookURL: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{WebhookURL: "https://example.com"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "",
- Group: "group",
+ Config: Config{WebhookURL: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "group",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -227,64 +227,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
+ t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
}
})
}
diff --git a/alerting/provider/teams/teams.go b/alerting/provider/teams/teams.go
index e61e2ee4..bd9e9295 100644
--- a/alerting/provider/teams/teams.go
+++ b/alerting/provider/teams/teams.go
@@ -3,6 +3,7 @@ package teams
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,54 +11,85 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"`
+ Title string `yaml:"title,omitempty"` // Title of the message that will be sent
+
+ // ClientConfig is the configuration of the client used to communicate with the provider's target
+ ClientConfig *client.Config `yaml:"client,omitempty"`
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.WebhookURL) == 0 {
+ return ErrWebhookURLNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if override.ClientConfig != nil {
+ cfg.ClientConfig = override.ClientConfig
+ }
+ if len(override.WebhookURL) > 0 {
+ cfg.WebhookURL = override.WebhookURL
+ }
+ if len(override.Title) > 0 {
+ cfg.Title = override.Title
+ }
+}
+
// AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct {
- 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 *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 []Override `yaml:"overrides,omitempty"`
-
- // Title is the title of the message that will be sent
- Title string `yaml:"title,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- WebhookURL string `yaml:"webhook-url"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
- if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
- return false
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return len(provider.WebhookURL) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
- request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
- response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
+ response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
@@ -84,7 +116,7 @@ type Section struct {
}
// 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
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)
@@ -111,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: color,
- Title: provider.Title,
+ Title: cfg.Title,
Text: message + description,
}
if len(body.Title) == 0 {
@@ -127,19 +159,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON
}
-// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
-func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
- if provider.Overrides != nil {
- for _, override := range provider.Overrides {
- if group == override.Group {
- return override.WebhookURL
- }
- }
- }
- return provider.WebhookURL
-}
-
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// 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
+}
diff --git a/alerting/provider/teams/teams_test.go b/alerting/provider/teams/teams_test.go
index 802a070e..f2ce4cf2 100644
--- a/alerting/provider/teams/teams_test.go
+++ b/alerting/provider/teams/teams_test.go
@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{WebhookURL: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{WebhookURL: "http://example.com"}
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "",
- Group: "group",
+ Config: Config{WebhookURL: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "group",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
+ t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
}
})
}
diff --git a/alerting/provider/teamsworkflows/teamsworkflows.go b/alerting/provider/teamsworkflows/teamsworkflows.go
index 792ef749..98daba8f 100644
--- a/alerting/provider/teamsworkflows/teamsworkflows.go
+++ b/alerting/provider/teamsworkflows/teamsworkflows.go
@@ -3,6 +3,7 @@ package teamsworkflows
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,46 +11,74 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"`
+ Title string `yaml:"title,omitempty"` // Title of the message that will be sent
+}
+
+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
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 *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
-
- // Title is the title of the message that will be sent
- Title string `yaml:"title,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
- Group string `yaml:"group"`
- WebhookURL string `yaml:"webhook-url"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
- if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
- return false
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return len(provider.WebhookURL) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
- request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
@@ -106,7 +135,7 @@ type Fact struct {
}
// 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 themeColor string
if resolved {
@@ -119,8 +148,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
// Configure default title if it's not provided
title := "⛑ Gatus"
- if provider.Title != "" {
- title = provider.Title
+ if cfg.Title != "" {
+ title = cfg.Title
}
// Build the facts from the condition results
@@ -189,19 +218,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON
}
-// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
-func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
- if provider.Overrides != nil {
- for _, override := range provider.Overrides {
- if group == override.Group {
- return override.WebhookURL
- }
- }
- }
- return provider.WebhookURL
-}
-
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// 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
+}
diff --git a/alerting/provider/teamsworkflows/teamsworkflows_test.go b/alerting/provider/teamsworkflows/teamsworkflows_test.go
index c6221ef1..58deafc5 100644
--- a/alerting/provider/teamsworkflows/teamsworkflows_test.go
+++ b/alerting/provider/teamsworkflows/teamsworkflows_test.go
@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertDefaultProvider_IsValid(t *testing.T) {
- invalidProvider := AlertProvider{WebhookURL: ""}
- if invalidProvider.IsValid() {
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
- validProvider := AlertProvider{WebhookURL: "http://example.com"}
- if !validProvider.IsValid() {
- t.Error("provider should've been valid")
+ validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
+ if err := validProvider.Validate(); err != nil {
+ t.Error("provider should've been valid, got", err.Error())
}
}
-func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "",
},
},
}
- if providerWithInvalidOverrideGroup.IsValid() {
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
- WebhookURL: "",
- Group: "group",
+ Config: Config{WebhookURL: ""},
+ Group: "group",
},
},
}
- if providerWithInvalidOverrideTo.IsValid() {
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- WebhookURL: "http://example.com",
- Group: "group",
+ Config: Config{WebhookURL: "http://example.com"},
+ Group: "group",
},
},
}
- if !providerWithValidOverride.IsValid() {
+ if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
-func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
- tests := []struct {
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
- ExpectedOutput string
+ InputAlert alert.Alert
+ ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
- Overrides: nil,
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
+ Overrides: nil,
},
InputGroup: "group",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
- ExpectedOutput: "http://example.com",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
- WebhookURL: "http://example.com",
+ DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
- Group: "group",
- WebhookURL: "http://example01.com",
+ Group: "group",
+ Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
- t.Run(tt.Name, func(t *testing.T) {
- if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
- t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
+ t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
}
})
}
diff --git a/alerting/provider/telegram/telegram.go b/alerting/provider/telegram/telegram.go
index ecfd700b..b2624c7c 100644
--- a/alerting/provider/telegram/telegram.go
+++ b/alerting/provider/telegram/telegram.go
@@ -3,6 +3,7 @@ package telegram
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,66 +11,97 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
-const 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
type AlertProvider struct {
- Token string `yaml:"token"`
- 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"`
+ DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
- // Overrides is a list of 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"`
}
// Override is a configuration that may be prioritized over the default configuration
type Override struct {
- group string `yaml:"group"`
- token string `yaml:"token"`
- id string `yaml:"id"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- if provider.ClientConfig == nil {
- provider.ClientConfig = client.GetDefaultConfig()
- }
-
- registerGroups := make(map[string]bool)
- for _, override := range provider.Overrides {
- if len(override.group) == 0 {
- return false
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
}
- if _, ok := registerGroups[override.group]; ok {
- return false
- }
- registerGroups[override.group] = true
}
-
- return len(provider.Token) > 0 && len(provider.ID) > 0
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
- apiURL := provider.APIURL
- if apiURL == "" {
- apiURL = defaultAPIURL
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
}
- 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 {
return err
}
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 {
return err
}
@@ -81,15 +113,6 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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 {
ChatID string `json:"chat_id"`
Text string `json:"text"`
@@ -97,7 +120,7 @@ type Body struct {
}
// 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
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)
@@ -124,23 +147,45 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
}
bodyAsJSON, _ := json.Marshal(Body{
- ChatID: provider.getIDForGroup(ep.Group),
+ ChatID: cfg.ID,
Text: text,
ParseMode: "MARKDOWN",
})
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/telegram/telegram_test.go b/alerting/provider/telegram/telegram_test.go
index c1f201cb..2d4fd6ab 100644
--- a/alerting/provider/telegram/telegram_test.go
+++ b/alerting/provider/telegram/telegram_test.go
@@ -11,87 +11,36 @@ import (
"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) {
- invalidProvider := AlertProvider{Token: "", ID: ""}
- if invalidProvider.IsValid() {
+ invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
- validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
- if validProvider.ClientConfig != nil {
- t.Error("provider client config should have been nil prior to IsValid() being executed")
- }
- if !validProvider.IsValid() {
+ validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
+ if err := validProvider.Validate(); err != nil {
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) {
- invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{token: "token", id: "id"}}}
- if invalidProvider.IsValid() {
+ invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}}
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
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"}}}
- if invalidProvider.IsValid() {
+ 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 err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
- t.Run("valid-provider", func(t *testing.T) {
- validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "token", id: "id"}}}
- if validProvider.ClientConfig != nil {
- t.Error("provider client config should have been nil prior to IsValid() being executed")
- }
- if !validProvider.IsValid() {
+ t.Run("valid-provider-with-overrides", func(t *testing.T) {
+ validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "token", ID: "id"}}}}
+ if err := validProvider.Validate(); err != nil {
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",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -119,7 +68,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -129,7 +78,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
- Provider: AlertProvider{},
+ Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -139,7 +88,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -185,14 +134,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
- Provider: AlertProvider{ID: "123"},
+ Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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\"}",
},
{
Name: "resolved",
- Provider: AlertProvider{ID: "123"},
+ Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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\"}",
@@ -200,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "resolved-with-no-conditions",
NoConditions: true,
- Provider: AlertProvider{ID: "123"},
+ Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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\"}",
@@ -216,6 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
@@ -240,3 +190,63 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
+ }
+ })
+}
diff --git a/alerting/provider/twilio/twilio.go b/alerting/provider/twilio/twilio.go
index b2879444..544397fb 100644
--- a/alerting/provider/twilio/twilio.go
+++ b/alerting/provider/twilio/twilio.go
@@ -3,6 +3,7 @@ package twilio
import (
"bytes"
"encoding/base64"
+ "errors"
"fmt"
"io"
"net/http"
@@ -11,33 +12,80 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
)
-// AlertProvider is the configuration necessary for sending an alert using Twilio
-type AlertProvider struct {
+var (
+ 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"`
Token string `yaml:"token"`
From string `yaml:"from"`
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 *alert.Alert `yaml:"default-alert,omitempty"`
}
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
- return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
- request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ 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 {
return err
}
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)
if err != nil {
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
-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
if resolved {
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())
}
return url.Values{
- "To": {provider.To},
- "From": {provider.From},
+ "To": {cfg.To},
+ "From": {cfg.From},
"Body": {message},
}.Encode()
}
@@ -69,3 +117,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle 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
+}
diff --git a/alerting/provider/twilio/twilio_test.go b/alerting/provider/twilio/twilio_test.go
index 66e1737f..f31f6349 100644
--- a/alerting/provider/twilio/twilio_test.go
+++ b/alerting/provider/twilio/twilio_test.go
@@ -1,28 +1,110 @@
package twilio
import (
+ "net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
- if invalidProvider.IsValid() {
+ if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
- SID: "1",
- Token: "1",
- From: "1",
- To: "1",
+ DefaultConfig: Config{
+ SID: "1",
+ Token: "1",
+ From: "1",
+ To: "1",
+ },
}
- if !validProvider.IsValid() {
+ if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ MockRoundTripper test.MockRoundTripper
+ ExpectedError bool
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{DefaultConfig: Config{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) {
firstDescription := "description-1"
secondDescription := "description-2"
@@ -35,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
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},
Resolved: true,
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 {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
+ &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -76,3 +159,53 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/zulip/zulip.go b/alerting/provider/zulip/zulip.go
index 5f2a408d..9160cf9d 100644
--- a/alerting/provider/zulip/zulip.go
+++ b/alerting/provider/zulip/zulip.go
@@ -2,6 +2,7 @@ package zulip
import (
"bytes"
+ "errors"
"fmt"
"io"
"net/http"
@@ -10,108 +11,99 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ 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 {
- // BotEmail is the email of the bot user
- BotEmail string `yaml:"bot-email"`
- // BotAPIKey is the API key of the bot user
- BotAPIKey string `yaml:"bot-api-key"`
- // Domain is the domain of the Zulip server
- Domain string `yaml:"domain"`
- // ChannelID is the ID of the channel to send the message to
- ChannelID string `yaml:"channel-id"`
+ BotEmail string `yaml:"bot-email"` // Email of the bot user
+ BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
+ Domain string `yaml:"domain"` // Domain of the Zulip server
+ ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
+}
+
+func (cfg *Config) Validate() error {
+ 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
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 *alert.Alert `yaml:"default-alert,omitempty"`
+
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
- Config
- Group string `yaml:"group"`
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
}
-func (provider *AlertProvider) validateConfig(conf *Config) bool {
- return len(conf.BotEmail) > 0 && len(conf.BotAPIKey) > 0 && len(conf.Domain) > 0 && len(conf.ChannelID) > 0
-}
-
-// IsValid returns whether the provider's configuration is valid
-func (provider *AlertProvider) IsValid() bool {
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
- isAlreadyRegistered := registeredGroups[override.Group]
- if isAlreadyRegistered || override.Group == "" || !provider.validateConfig(&override.Config) {
- return false
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
- return provider.validateConfig(&provider.Config)
-}
-
-// 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()
+ return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
- buffer := bytes.NewBufferString(provider.buildRequestBody(ep, alert, result, resolved))
- zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", provider.Domain)
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ 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)
if err != nil {
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("User-Agent", "Gatus")
response, err := client.GetHTTPClient(nil).Do(request)
@@ -126,7 +118,66 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/zulip/zulip_test.go b/alerting/provider/zulip/zulip_test.go
index 3d481ecd..8d9c34f3 100644
--- a/alerting/provider/zulip/zulip_test.go
+++ b/alerting/provider/zulip/zulip_test.go
@@ -1,6 +1,7 @@
package zulip
import (
+ "errors"
"fmt"
"net/http"
"net/url"
@@ -12,237 +13,84 @@ import (
"github.com/TwiN/gatus/v5/test"
)
-func TestAlertProvider_IsValid(t *testing.T) {
- testCase := []struct {
- name string
- alertProvider AlertProvider
- expected bool
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ AlertProvider AlertProvider
+ ExpectedError error
}{
{
- name: "Empty provider",
- alertProvider: AlertProvider{},
- expected: false,
+ Name: "Empty provider",
+ AlertProvider: AlertProvider{},
+ ExpectedError: ErrBotEmailNotSet,
},
{
- name: "Empty channel id",
- alertProvider: AlertProvider{
- Config: Config{
+ Name: "Empty channel id",
+ AlertProvider: AlertProvider{
+ DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
- expected: false,
+ ExpectedError: ErrChannelIDNotSet,
},
{
- name: "Empty domain",
- alertProvider: AlertProvider{
- Config: Config{
+ Name: "Empty domain",
+ AlertProvider: AlertProvider{
+ DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
- expected: false,
+ ExpectedError: ErrDomainNotSet,
},
{
- name: "Empty bot api key",
- alertProvider: AlertProvider{
- Config: Config{
+ Name: "Empty bot api key",
+ AlertProvider: AlertProvider{
+ DefaultConfig: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
- expected: false,
+ ExpectedError: ErrBotAPIKeyNotSet,
},
{
- name: "Empty bot email",
- alertProvider: AlertProvider{
- Config: Config{
+ Name: "Empty bot email",
+ AlertProvider: AlertProvider{
+ DefaultConfig: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
- expected: false,
+ ExpectedError: ErrBotEmailNotSet,
},
{
- name: "Valid provider",
- alertProvider: AlertProvider{
- Config: Config{
+ Name: "Valid provider",
+ AlertProvider: AlertProvider{
+ DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
- expected: true,
+ ExpectedError: nil,
},
}
- 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)
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ if err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {
+ t.Errorf("ExpectedError error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
-func TestAlertProvider_IsValidWithOverride(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) {
+func TestAlertProvider_buildRequestBody(t *testing.T) {
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
@@ -266,13 +114,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Resolved alert with no conditions",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: false,
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
`},
"to": {"channel-id"},
@@ -283,13 +131,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Resolved alert with conditions",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: true,
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
:check: - ` + "`[CONNECTED] == true`" + `
@@ -303,13 +151,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Failed alert with no conditions",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: false,
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
`},
"to": {"channel-id"},
@@ -320,13 +168,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Failed alert with conditions",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: true,
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
:cross_mark: - ` + "`[CONNECTED] == true`" + `
@@ -349,7 +197,8 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
}
}
body := tc.provider.buildRequestBody(
- &endpoint.Endpoint{Name: "endpoint-name"},
+ &tc.provider.DefaultConfig,
+ &endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: conditionResults,
@@ -369,10 +218,10 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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 {
- 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)
validateRequest := func(req *http.Request) {
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 {
- 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" {
- 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" {
- 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{
@@ -413,7 +262,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "resolved",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
@@ -426,7 +275,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "resolved error",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
@@ -439,7 +288,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "triggered",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
@@ -452,7 +301,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "triggered error",
provider: AlertProvider{
- Config: basicConfig,
+ DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
@@ -467,7 +316,7 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send(
- &endpoint.Endpoint{Name: "endpoint-name"},
+ &endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
@@ -478,10 +327,155 @@ func TestAlertProvider_Send(t *testing.T) {
tc.resolved,
)
if tc.expectedError && err == nil {
- t.Error("expected error, got none")
+ t.Error("ExpectedError error, got none")
}
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)
}
})
}
diff --git a/config/config.go b/config/config.go
index 210752f4..a666c63f 100644
--- a/config/config.go
+++ b/config/config.go
@@ -22,6 +22,7 @@ import (
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/logr"
+ "github.com/gofiber/fiber/v2/log"
"gopkg.in/yaml.v3"
)
@@ -421,14 +422,20 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, alertType := range alertTypes {
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
if alertProvider != nil {
- if alertProvider.IsValid() {
+ if err := alertProvider.Validate(); err == nil {
// Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil {
for _, ep := range endpoints {
for alertIndex, endpointAlert := range ep.Alerts {
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())
- 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 {
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())
- 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)
} 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)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
diff --git a/config/config_test.go b/config/config_test.go
index 4650626b..8ed3ddf1 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -11,10 +11,13 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"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/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
+ "github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
+ "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
@@ -30,6 +33,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
+ "github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/web"
@@ -198,8 +202,8 @@ endpoints:
expectedConfig: &Config{
Metrics: true,
Alerting: &alerting.Config{
- Discord: &discord.AlertProvider{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}},
+ Discord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}},
+ Slack: &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, DefaultAlert: &alert.Alert{Enabled: &yes}},
},
ExternalEndpoints: []*endpoint.ExternalEndpoint{
{
@@ -481,7 +485,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
- t.Fatal("Config shouldn't have been nil")
+ t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
@@ -786,7 +790,7 @@ endpoints:
if config.Alerting == 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")
}
// Endpoints
@@ -1037,63 +1041,64 @@ endpoints:
if config.Alerting == 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")
}
if config.Alerting.Slack.GetDefaultAlert() == nil {
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.Slack.WebhookURL != "http://example.com" {
- t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.WebhookURL)
+ 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.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")
}
if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
- t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
+ if config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != "00000000000000000000000000000000" {
+ 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")
}
if config.Alerting.Pushover.GetDefaultAlert() == nil {
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.Pushover.ApplicationToken != "000000000000000000000000000000" {
- t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.ApplicationToken)
+ if config.Alerting.Pushover.DefaultConfig.ApplicationToken != "000000000000000000000000000000" {
+ t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.ApplicationToken)
}
- if config.Alerting.Pushover.UserKey != "000000000000000000000000000000" {
- t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.UserKey)
+ if config.Alerting.Pushover.DefaultConfig.UserKey != "000000000000000000000000000000" {
+ 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")
}
if config.Alerting.Mattermost.GetDefaultAlert() == 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")
}
if config.Alerting.Messagebird.GetDefaultAlert() == nil {
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.Messagebird.AccessKey != "1" {
- t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey)
+ if config.Alerting.Messagebird.DefaultConfig.AccessKey != "1" {
+ t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.DefaultConfig.AccessKey)
}
- if config.Alerting.Messagebird.Originator != "31619191918" {
- t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.Originator)
+ if config.Alerting.Messagebird.DefaultConfig.Originator != "31619191918" {
+ t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.DefaultConfig.Originator)
}
- if config.Alerting.Messagebird.Recipients != "31619191919" {
- t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients)
+ if config.Alerting.Messagebird.DefaultConfig.Recipients != "31619191919" {
+ 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")
}
if config.Alerting.Discord.GetDefaultAlert() == nil {
@@ -1105,98 +1110,98 @@ endpoints:
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)
}
- if config.Alerting.Discord.WebhookURL != "http://example.org" {
- t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL)
+ 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.DefaultConfig.WebhookURL)
}
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
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")
}
if config.Alerting.Telegram.GetDefaultAlert() == nil {
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.Telegram.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
- t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.Token)
+ 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.DefaultConfig.Token)
}
- if config.Alerting.Telegram.ID != "0123456789" {
- t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.ID)
+ if config.Alerting.Telegram.DefaultConfig.ID != "0123456789" {
+ 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")
}
if config.Alerting.Twilio.GetDefaultAlert() == 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")
}
if config.Alerting.Teams.GetDefaultAlert() == 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")
}
-
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.JetBrainsSpace.Project != "foo" {
- t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.Project)
+ if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
+ t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
}
- if config.Alerting.JetBrainsSpace.ChannelID != "bar" {
- t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.ChannelID)
+ if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
+ t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
}
- if config.Alerting.JetBrainsSpace.Token != "baz" {
- t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token)
+ if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
+ 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")
}
if config.Alerting.Email.GetDefaultAlert() == nil {
t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.Email.From != "from@example.com" {
- t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.From)
+ 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.DefaultConfig.From)
}
- if config.Alerting.Email.Username != "from@example.com" {
- t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.Username)
+ 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.DefaultConfig.Username)
}
- if config.Alerting.Email.Password != "hunter2" {
- t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.Password)
+ if config.Alerting.Email.DefaultConfig.Password != "hunter2" {
+ 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" {
- t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.Host)
+ 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.DefaultConfig.Host)
}
- if config.Alerting.Email.Port != 587 {
- t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.Port)
+ if config.Alerting.Email.DefaultConfig.Port != 587 {
+ 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" {
- t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.To)
+ 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.DefaultConfig.To)
}
- if config.Alerting.Email.ClientConfig == nil {
+ if config.Alerting.Email.DefaultConfig.ClientConfig == nil {
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")
}
- 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")
}
if config.Alerting.Gotify.GetDefaultAlert() == nil {
t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil")
}
- if config.Alerting.Gotify.ServerURL != "https://gotify.example" {
- t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.ServerURL)
+ 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.DefaultConfig.ServerURL)
}
- if config.Alerting.Gotify.Token != "**************" {
- t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.Token)
+ if config.Alerting.Gotify.DefaultConfig.Token != "**************" {
+ t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.DefaultConfig.Token)
}
// External endpoints
@@ -1405,6 +1410,8 @@ endpoints:
- type: slack
enabled: false
failure-threshold: 30
+ provider-override:
+ webhook-url: https://example.com
conditions:
- "[STATUS] == 200"
`))
@@ -1418,7 +1425,7 @@ endpoints:
if config.Alerting == 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")
}
// Endpoints
@@ -1546,17 +1553,18 @@ endpoints:
if config.Alerting.Custom == 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")
}
- if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
- t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(true))
+ cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{ProviderOverride: map[string]any{"client": map[string]any{"insecure": 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" {
- t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(false))
+ 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(cfg, false))
}
- if config.Alerting.Custom.ClientConfig.Insecure {
- t.Errorf("ClientConfig.Insecure should have been %v, got %v", false, config.Alerting.Custom.ClientConfig.Insecure)
+ if !cfg.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())
}
if config == nil {
- t.Fatal("Config shouldn't have been nil")
+ t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
@@ -1591,13 +1599,14 @@ endpoints:
if config.Alerting.Custom == 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")
}
- 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'")
}
- 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'")
}
}
@@ -1623,7 +1632,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
- t.Fatal("Config shouldn't have been nil")
+ t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
@@ -1631,13 +1640,14 @@ endpoints:
if config.Alerting.Custom == 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")
}
- 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'")
}
- 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'")
}
}
@@ -1813,7 +1823,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
- t.Fatal("Config shouldn't have been nil")
+ t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil")
@@ -1846,7 +1856,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
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" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
@@ -1868,34 +1878,41 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
- Custom: &custom.AlertProvider{},
- Discord: &discord.AlertProvider{},
- Email: &email.AlertProvider{},
- GitHub: &github.AlertProvider{},
- GoogleChat: &googlechat.AlertProvider{},
- Gotify: &gotify.AlertProvider{},
- JetBrainsSpace: &jetbrainsspace.AlertProvider{},
- Matrix: &matrix.AlertProvider{},
- Mattermost: &mattermost.AlertProvider{},
- Messagebird: &messagebird.AlertProvider{},
- Ntfy: &ntfy.AlertProvider{},
- Opsgenie: &opsgenie.AlertProvider{},
- PagerDuty: &pagerduty.AlertProvider{},
- Pushover: &pushover.AlertProvider{},
- Slack: &slack.AlertProvider{},
- Telegram: &telegram.AlertProvider{},
- Twilio: &twilio.AlertProvider{},
- Teams: &teams.AlertProvider{},
- TeamsWorkflows: &teamsworkflows.AlertProvider{},
+ AWSSimpleEmailService: &awsses.AlertProvider{},
+ Custom: &custom.AlertProvider{},
+ Discord: &discord.AlertProvider{},
+ Email: &email.AlertProvider{},
+ Gitea: &gitea.AlertProvider{},
+ GitHub: &github.AlertProvider{},
+ GitLab: &gitlab.AlertProvider{},
+ GoogleChat: &googlechat.AlertProvider{},
+ Gotify: &gotify.AlertProvider{},
+ JetBrainsSpace: &jetbrainsspace.AlertProvider{},
+ Matrix: &matrix.AlertProvider{},
+ Mattermost: &mattermost.AlertProvider{},
+ Messagebird: &messagebird.AlertProvider{},
+ Ntfy: &ntfy.AlertProvider{},
+ Opsgenie: &opsgenie.AlertProvider{},
+ PagerDuty: &pagerduty.AlertProvider{},
+ Pushover: &pushover.AlertProvider{},
+ Slack: &slack.AlertProvider{},
+ Telegram: &telegram.AlertProvider{},
+ Teams: &teams.AlertProvider{},
+ TeamsWorkflows: &teamsworkflows.AlertProvider{},
+ Twilio: &twilio.AlertProvider{},
+ Zulip: &zulip.AlertProvider{},
}
scenarios := []struct {
alertType alert.Type
expected provider.AlertProvider
}{
+ {alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
+ {alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
+ {alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
@@ -1908,9 +1925,10 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
- {alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
+ {alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
+ {alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
}
for _, scenario := range scenarios {
t.Run(string(scenario.alertType), func(t *testing.T) {
diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go
index 001db55b..2dffd909 100644
--- a/watchdog/alerting_test.go
+++ b/watchdog/alerting_test.go
@@ -30,8 +30,10 @@ func TestHandleAlerting(t *testing.T) {
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
- URL: "https://twin.sh/health",
- Method: "GET",
+ DefaultConfig: custom.Config{
+ URL: "https://twin.sh/health",
+ Method: "GET",
+ },
},
},
}
@@ -108,8 +110,10 @@ func TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButendpointStartFailing
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
- URL: "https://twin.sh/health",
- Method: "GET",
+ DefaultConfig: custom.Config{
+ URL: "https://twin.sh/health",
+ Method: "GET",
+ },
},
},
}
@@ -141,8 +145,10 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *t
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
- URL: "https://twin.sh/health",
- Method: "GET",
+ DefaultConfig: custom.Config{
+ URL: "https://twin.sh/health",
+ Method: "GET",
+ },
},
},
}
@@ -174,7 +180,9 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) {
cfg := &config.Config{
Alerting: &alerting.Config{
PagerDuty: &pagerduty.AlertProvider{
- IntegrationKey: "00000000000000000000000000000000",
+ DefaultConfig: pagerduty.Config{
+ IntegrationKey: "00000000000000000000000000000000",
+ },
},
},
}
@@ -208,8 +216,10 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPushover(t *testing.T) {
cfg := &config.Config{
Alerting: &alerting.Config{
Pushover: &pushover.AlertProvider{
- ApplicationToken: "000000000000000000000000000000",
- UserKey: "000000000000000000000000000000",
+ DefaultConfig: pushover.Config{
+ ApplicationToken: "000000000000000000000000000000",
+ UserKey: "000000000000000000000000000000",
+ },
},
},
}
@@ -250,8 +260,10 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeCustom,
AlertingConfig: &alerting.Config{
Custom: &custom.AlertProvider{
- URL: "https://twin.sh/health",
- Method: "GET",
+ DefaultConfig: custom.Config{
+ URL: "https://twin.sh/health",
+ Method: "GET",
+ },
},
},
},
@@ -260,7 +272,9 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeDiscord,
AlertingConfig: &alerting.Config{
Discord: &discord.AlertProvider{
- WebhookURL: "https://example.com",
+ DefaultConfig: discord.Config{
+ WebhookURL: "https://example.com",
+ },
},
},
},
@@ -269,11 +283,13 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeEmail,
AlertingConfig: &alerting.Config{
Email: &email.AlertProvider{
- From: "from@example.com",
- Password: "hunter2",
- Host: "mail.example.com",
- Port: 587,
- To: "to@example.com",
+ DefaultConfig: email.Config{
+ From: "from@example.com",
+ Password: "hunter2",
+ Host: "mail.example.com",
+ Port: 587,
+ To: "to@example.com",
+ },
},
},
},
@@ -282,9 +298,11 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeJetBrainsSpace,
AlertingConfig: &alerting.Config{
JetBrainsSpace: &jetbrainsspace.AlertProvider{
- Project: "foo",
- ChannelID: "bar",
- Token: "baz",
+ DefaultConfig: jetbrainsspace.Config{
+ Project: "foo",
+ ChannelID: "bar",
+ Token: "baz",
+ },
},
},
},
@@ -293,7 +311,9 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeMattermost,
AlertingConfig: &alerting.Config{
Mattermost: &mattermost.AlertProvider{
- WebhookURL: "https://example.com",
+ DefaultConfig: mattermost.Config{
+ WebhookURL: "https://example.com",
+ },
},
},
},
@@ -302,9 +322,11 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeMessagebird,
AlertingConfig: &alerting.Config{
Messagebird: &messagebird.AlertProvider{
- AccessKey: "1",
- Originator: "2",
- Recipients: "3",
+ DefaultConfig: messagebird.Config{
+ AccessKey: "1",
+ Originator: "2",
+ Recipients: "3",
+ },
},
},
},
@@ -313,7 +335,9 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypePagerDuty,
AlertingConfig: &alerting.Config{
PagerDuty: &pagerduty.AlertProvider{
- IntegrationKey: "00000000000000000000000000000000",
+ DefaultConfig: pagerduty.Config{
+ IntegrationKey: "00000000000000000000000000000000",
+ },
},
},
},
@@ -322,8 +346,10 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypePushover,
AlertingConfig: &alerting.Config{
Pushover: &pushover.AlertProvider{
- ApplicationToken: "000000000000000000000000000000",
- UserKey: "000000000000000000000000000000",
+ DefaultConfig: pushover.Config{
+ ApplicationToken: "000000000000000000000000000000",
+ UserKey: "000000000000000000000000000000",
+ },
},
},
},
@@ -332,7 +358,9 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeSlack,
AlertingConfig: &alerting.Config{
Slack: &slack.AlertProvider{
- WebhookURL: "https://example.com",
+ DefaultConfig: slack.Config{
+ WebhookURL: "https://example.com",
+ },
},
},
},
@@ -341,7 +369,9 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeTeams,
AlertingConfig: &alerting.Config{
Teams: &teams.AlertProvider{
- WebhookURL: "https://example.com",
+ DefaultConfig: teams.Config{
+ WebhookURL: "https://example.com",
+ },
},
},
},
@@ -350,8 +380,10 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeTelegram,
AlertingConfig: &alerting.Config{
Telegram: &telegram.AlertProvider{
- Token: "1",
- ID: "2",
+ DefaultConfig: telegram.Config{
+ Token: "1",
+ ID: "2",
+ },
},
},
},
@@ -360,10 +392,12 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeTwilio,
AlertingConfig: &alerting.Config{
Twilio: &twilio.AlertProvider{
- SID: "1",
- Token: "2",
- From: "3",
- To: "4",
+ DefaultConfig: twilio.Config{
+ SID: "1",
+ Token: "2",
+ From: "3",
+ To: "4",
+ },
},
},
},
@@ -372,7 +406,7 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
AlertType: alert.TypeMatrix,
AlertingConfig: &alerting.Config{
Matrix: &matrix.AlertProvider{
- ProviderConfig: matrix.ProviderConfig{
+ DefaultConfig: matrix.Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -437,8 +471,10 @@ func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) {
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
- URL: "https://twin.sh/health",
- Method: "GET",
+ DefaultConfig: custom.Config{
+ URL: "https://twin.sh/health",
+ Method: "GET",
+ },
},
},
}