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", + }, }, }, }