diff --git a/alerting/alert/alert.go b/alerting/alert/alert.go index c97feef8..d1ee06f1 100644 --- a/alerting/alert/alert.go +++ b/alerting/alert/alert.go @@ -36,13 +36,18 @@ 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"` + + // Override 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. + // Deepmerged with https://github.com/TwiN/deepmerge + Override []byte `yaml:"override,omitempty"` // ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve // ongoing/triggered incidents diff --git a/alerting/provider/awsses/awsses.go b/alerting/provider/awsses/awsses.go index a43a9138..621c0564 100644 --- a/alerting/provider/awsses/awsses.go +++ b/alerting/provider/awsses/awsses.go @@ -1,6 +1,7 @@ package awsses import ( + "errors" "fmt" "strings" @@ -12,22 +13,18 @@ import ( "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 { - 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"` -} +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"` @@ -38,38 +35,80 @@ type Config struct { 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"` + + // 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"` } -// 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{ @@ -87,7 +126,7 @@ 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), } if _, err = svc.SendEmail(input); err != nil { if aerr, ok := err.(awserr.Error); ok { @@ -112,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 @@ -142,29 +191,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) } - return session.NewSession(config) + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil } diff --git a/alerting/provider/awsses/awsses_test.go b/alerting/provider/awsses/awsses_test.go index 05dadb62..2943c6a6 100644 --- a/alerting/provider/awsses/awsses_test.go +++ b/alerting/provider/awsses/awsses_test.go @@ -9,19 +9,19 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } invalidProviderWithOneKey := AlertProvider{Config: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}} - if invalidProviderWithOneKey.IsValid() { + if invalidProviderWithOneKey.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{From: "from@example.com", To: "to@example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } validProviderWithKeys := AlertProvider{Config: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}} - if !validProviderWithKeys.IsValid() { + if !validProviderWithKeys.Validate() { t.Error("provider should've been valid") } } @@ -35,7 +35,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -46,7 +46,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -61,7 +61,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } @@ -126,12 +126,13 @@ 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", @@ -142,7 +143,8 @@ func TestAlertProvider_getToForGroup(t *testing.T) { Overrides: nil, }, InputGroup: "", - ExpectedOutput: "to@example.com", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{To: "to@example.com"}, }, { Name: "provider-no-override-specify-group-should-default", @@ -153,7 +155,8 @@ func TestAlertProvider_getToForGroup(t *testing.T) { Overrides: nil, }, InputGroup: "group", - ExpectedOutput: "to@example.com", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{To: "to@example.com"}, }, { Name: "provider-with-override-specify-no-group-should-default", @@ -164,12 +167,13 @@ func TestAlertProvider_getToForGroup(t *testing.T) { Overrides: []Override{ { Group: "group", - Config: Config{To: "to01@example.com"}, + Config: Config{To: "groupto@example.com"}, }, }, }, InputGroup: "", - ExpectedOutput: "to@example.com", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{To: "to@example.com"}, }, { Name: "provider-with-override-specify-group-should-override", @@ -180,18 +184,56 @@ func TestAlertProvider_getToForGroup(t *testing.T) { Overrides: []Override{ { Group: "group", - Config: Config{To: "to01@example.com"}, + Config: Config{To: "groupto@example.com"}, }, }, }, InputGroup: "group", - ExpectedOutput: "to01@example.com", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{To: "groupto@example.com"}, + }, + { + Name: "provider-with-override-specify-group-but-alert-override-should-override-group-override", + Provider: AlertProvider{ + Config: Config{ + From: "from@example.com", + To: "to@example.com", + }, + Overrides: []Override{ + { + Group: "group", + Config: Config{To: "groupto@example.com", SecretAccessKey: "sekrit"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{ + Override: []byte(`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.GetConfigWithOverrides(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) } }) } diff --git a/alerting/provider/custom/custom.go b/alerting/provider/custom/custom.go index bb83b473..701066c1 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,16 +11,12 @@ 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 { - 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"` -} +var ( + ErrURLNotSet = errors.New("url not set") +) type Config struct { URL string `yaml:"url"` @@ -32,61 +29,64 @@ type Config struct { 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 cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() } - return len(provider.URL) > 0 && provider.ClientConfig != nil + if len(cfg.URL) == 0 { + return ErrURLNotSet + } + return nil } -// 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" +func (cfg *Config) Merge(override *Config) { + if len(override.URL) > 0 { + cfg.URL = override.URL } - if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok { - if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok { - return val - } + 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 } - 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 +// 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"` +} + +// 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 { + 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 } @@ -98,7 +98,78 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/custom/custom_test.go b/alerting/provider/custom/custom_test.go index 17171731..8dc97288 100644 --- a/alerting/provider/custom/custom_test.go +++ b/alerting/provider/custom/custom_test.go @@ -15,7 +15,7 @@ import ( func TestAlertProvider_IsValid(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) { invalidProvider := AlertProvider{Config: Config{URL: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } }) @@ -24,7 +24,7 @@ func TestAlertProvider_IsValid(t *testing.T) { if validProvider.ClientConfig != nil { t.Error("provider client config should have been nil prior to IsValid() being executed") } - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } if validProvider.ClientConfig == nil { diff --git a/alerting/provider/discord/discord.go b/alerting/provider/discord/discord.go index 80009b9d..930dda4b 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,11 +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"` + 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 { - 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"` @@ -23,35 +51,33 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - WebhookURL string `yaml:"webhook-url"` - Title string `yaml:"title,omitempty"` // Title of the message that will be sent -} - -// Override is a case under which the default integration is overridden type Override struct { 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 } @@ -87,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 { @@ -112,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: "", @@ -136,19 +162,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/discord/discord_test.go b/alerting/provider/discord/discord_test.go index 66863e7a..0c3c52a3 100644 --- a/alerting/provider/discord/discord_test.go +++ b/alerting/provider/discord/discord_test.go @@ -12,12 +12,12 @@ import ( ) func TestAlertProvider_IsValid(t *testing.T) { - invalidProvider := AlertProvider{Config: Config{WebhookURL: ""}} - if invalidProvider.IsValid() { + invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}} + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } - validProvider := AlertProvider{Config: Config{WebhookURL: "http://example.com"}} - if !validProvider.IsValid() { + validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}} + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -31,7 +31,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -42,11 +42,11 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ - Config: Config{ + DefaultConfig: Config{ WebhookURL: "http://example.com", }, Overrides: []Override{ @@ -56,7 +56,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } @@ -116,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) { }, { Name: "triggered-with-modified-title", - Provider: AlertProvider{Config: Config{Title: title}}, + Provider: AlertProvider{DefaultConfig: Config{Title: title}}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { @@ -177,7 +177,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }, { Name: "triggered-with-modified-title", - Provider: AlertProvider{Config: Config{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}]}]}", @@ -185,7 +185,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "triggered-with-no-conditions", NoConditions: true, - Provider: AlertProvider{Config: Config{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}]}", @@ -239,8 +239,8 @@ func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { { Name: "provider-no-override-specify-no-group-should-default", Provider: AlertProvider{ - Config: Config{WebhookURL: "http://example.com"}, - Overrides: nil, + DefaultConfig: Config{WebhookURL: "http://example.com"}, + Overrides: nil, }, InputGroup: "", ExpectedOutput: "http://example.com", @@ -248,8 +248,8 @@ func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { { Name: "provider-no-override-specify-group-should-default", Provider: AlertProvider{ - Config: Config{WebhookURL: "http://example.com"}, - Overrides: nil, + DefaultConfig: Config{WebhookURL: "http://example.com"}, + Overrides: nil, }, InputGroup: "group", ExpectedOutput: "http://example.com", @@ -257,7 +257,7 @@ func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { { Name: "provider-with-override-specify-no-group-should-default", Provider: AlertProvider{ - Config: Config{WebhookURL: "http://example.com"}, + DefaultConfig: Config{WebhookURL: "http://example.com"}, Overrides: []Override{ { Group: "group", @@ -271,7 +271,7 @@ func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { { Name: "provider-with-override-specify-group-should-override", Provider: AlertProvider{ - Config: Config{WebhookURL: "http://example.com"}, + DefaultConfig: Config{WebhookURL: "http://example.com"}, Overrides: []Override{ { Group: "group", diff --git a/alerting/provider/email/email.go b/alerting/provider/email/email.go index d4879980..67af5a06 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,11 +11,62 @@ import ( "github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/config/endpoint" gomail "gopkg.in/mail.v2" + "gopkg.in/yaml.v3" ) +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"` + Host string `yaml:"host"` + Port int `yaml:"port"` + To string `yaml:"to"` +} + +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 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 { - Config `yaml:",inline"` + DefaultConfig Config `yaml:",inline"` // ClientConfig is the configuration of the client used to communicate with the provider's target ClientConfig *client.Config `yaml:"client,omitempty"` @@ -26,63 +78,58 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - From string `yaml:"from"` - Username string `yaml:"username"` - Password string `yaml:"password"` - Host string `yaml:"host"` - Port int `yaml:"port"` - To string `yaml:"to"` -} - // Override is a case under which the default integration is overridden type Override struct { 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 { d.TLSConfig = &tls.Config{InsecureSkipVerify: true} @@ -120,19 +167,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/email/email_test.go b/alerting/provider/email/email_test.go index ba00e4d2..1ae9d6a5 100644 --- a/alerting/provider/email/email_test.go +++ b/alerting/provider/email/email_test.go @@ -9,18 +9,18 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) { validProvider := AlertProvider{Config: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -34,7 +34,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -45,7 +45,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -63,7 +63,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/gitea/gitea.go b/alerting/provider/gitea/gitea.go index 3adea3a4..cb222cb1 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,18 +12,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 Discord -type AlertProvider struct { - 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"` -} +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 Gitea repository to create issues in @@ -33,32 +30,37 @@ type Config struct { 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 cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() } - if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 { - return false + 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] + 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{ @@ -67,30 +69,51 @@ 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 } - provider.username = user.UserName - return true + cfg.username = user.UserName + return nil +} + +func (cfg *Config) Merge(override *Config) { + +} + +// 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(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 { @@ -98,13 +121,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, }, @@ -113,13 +135,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, @@ -160,3 +181,21 @@ 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(alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle alert overrides + if len(alert.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/gitea/gitea_test.go b/alerting/provider/gitea/gitea_test.go index 97ba90b3..37eab1c5 100644 --- a/alerting/provider/gitea/gitea_test.go +++ b/alerting/provider/gitea/gitea_test.go @@ -46,8 +46,8 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) { } 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 scenario.Provider.Validate() != scenario.Expected { + t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.Validate()) } }) } diff --git a/alerting/provider/github/github.go b/alerting/provider/github/github.go index 06ab4986..193512a2 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,15 +12,14 @@ 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 { - 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"` -} +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 @@ -31,53 +31,81 @@ type Config struct { 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] + 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(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)), }) @@ -85,9 +113,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 { @@ -95,7 +123,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 { @@ -134,3 +162,21 @@ 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(alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle alert overrides + if len(alert.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/github/github_test.go b/alerting/provider/github/github_test.go index 3e4f2a94..7919c537 100644 --- a/alerting/provider/github/github_test.go +++ b/alerting/provider/github/github_test.go @@ -46,8 +46,8 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) { } 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 scenario.Provider.Validate() != scenario.Expected { + t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.Validate()) } }) } diff --git a/alerting/provider/gitlab/gitlab.go b/alerting/provider/gitlab/gitlab.go index 896aa67a..47954e17 100644 --- a/alerting/provider/gitlab/gitlab.go +++ b/alerting/provider/gitlab/gitlab.go @@ -6,22 +6,19 @@ import ( "fmt" "io" "net/http" - "net/url" "time" "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/config/endpoint" "github.com/google/uuid" + "gopkg.in/yaml.v3" ) -// AlertProvider is the configuration necessary for sending an alert using GitLab -type AlertProvider struct { - 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"` -} +var ( + ErrWebhookURLNotSet = fmt.Errorf("webhook-url not set") + ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set") +) type Config struct { WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab @@ -32,32 +29,73 @@ type Config struct { Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name } -// 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 +func (cfg *Config) Validate() error { + if len(cfg.WebhookURL) == 0 { + return ErrWebhookURLNotSet } - // Validate format of the repository URL - _, err := url.Parse(provider.WebhookURL) - if err != nil { - return false + if len(cfg.AuthorizationKey) == 0 { + return ErrAuthorizationKeyNotSet } - return true + if len(cfg.Severity) == 0 { + cfg.Severity = "critical" + } + if len(cfg.MonitoringTool) == 0 { + cfg.MonitoringTool = "gatus" + } + 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 { + 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(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 @@ -83,30 +121,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 { @@ -144,3 +172,21 @@ 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(alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle alert overrides + if len(alert.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/gitlab/gitlab_test.go b/alerting/provider/gitlab/gitlab_test.go index 97154ee9..b3dbb730 100644 --- a/alerting/provider/gitlab/gitlab_test.go +++ b/alerting/provider/gitlab/gitlab_test.go @@ -40,8 +40,8 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) { } 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 scenario.Provider.Validate() != scenario.Expected { + t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.Validate()) } }) } diff --git a/alerting/provider/googlechat/googlechat.go b/alerting/provider/googlechat/googlechat.go index bbfb987b..192696f6 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,11 +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 cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() + } + 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 Google chat 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"` @@ -23,43 +51,39 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - WebhookURL string `yaml:"webhook-url"` - ClientConfig *client.Config `yaml:"client,omitempty"` // Configuration of the client used to communicate with the provider's target -} - // Override is a case under which the default integration is overridden type Override struct { 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 } @@ -187,19 +211,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/googlechat/googlechat_test.go b/alerting/provider/googlechat/googlechat_test.go index a05a81c3..19561c19 100644 --- a/alerting/provider/googlechat/googlechat_test.go +++ b/alerting/provider/googlechat/googlechat_test.go @@ -13,11 +13,11 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{WebhookURL: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{WebhookURL: "http://example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -31,7 +31,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -42,7 +42,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -54,7 +54,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/gotify/gotify.go b/alerting/provider/gotify/gotify.go index 70a3ef70..46aab4d5 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,37 +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 { - 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"` } -type Config struct { - ServerURL string `yaml:"server-url"` // ServerURL is the URL of the Gotify server - Token string `yaml:"token"` // Token is the token to use when sending a message to the Gotify server - Priority int `yaml:"priority,omitempty"` // Priority is the priority of the message. Defaults to DefaultPriority. - Title string `yaml:"title,omitempty"` // Title of the message that will be sent -} - -// 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(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 } @@ -64,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) @@ -86,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 } @@ -101,3 +137,21 @@ 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(alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle alert overrides + if len(alert.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/gotify/gotify_test.go b/alerting/provider/gotify/gotify_test.go index b8ffbc8f..3f2dad86 100644 --- a/alerting/provider/gotify/gotify_test.go +++ b/alerting/provider/gotify/gotify_test.go @@ -38,8 +38,8 @@ func TestAlertProvider_IsValid(t *testing.T) { } 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 scenario.provider.Validate() != scenario.expected { + t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.Validate()) } }) } diff --git a/alerting/provider/jetbrainsspace/jetbrainsspace.go b/alerting/provider/jetbrainsspace/jetbrainsspace.go index 72c2b841..d5b786fa 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,11 +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 { - 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"` @@ -23,42 +63,40 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - Project string `yaml:"project"` // Project name - ChannelID string `yaml:"channel-id"` // Chat Channel ID - Token string `yaml:"token"` // Bearer Token -} - // Override is a case under which the default integration is overridden type Override struct { 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 @@ -107,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{{ @@ -148,19 +186,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/jetbrainsspace/jetbrainsspace_test.go b/alerting/provider/jetbrainsspace/jetbrainsspace_test.go index 3775f783..3818fb60 100644 --- a/alerting/provider/jetbrainsspace/jetbrainsspace_test.go +++ b/alerting/provider/jetbrainsspace/jetbrainsspace_test.go @@ -13,11 +13,11 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{Project: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{Project: "foo", ChannelID: "bar", Token: "baz"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -32,7 +32,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -44,7 +44,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -60,7 +60,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/matrix/matrix.go b/alerting/provider/matrix/matrix.go index a93136d1..142591f8 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,20 +14,16 @@ 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 defaultServerURL = "https://matrix-client.matrix.org" -// AlertProvider is the configuration necessary for sending an alert using Matrix -type AlertProvider struct { - 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"` -} +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) @@ -39,42 +36,78 @@ type Config struct { InternalRoomID string `yaml:"internal-room-id"` } +func (cfg *Config) Validate() error { + if len(cfg.ServerURL) == 0 { + cfg.ServerURL = defaultServerURL + } + if len(cfg.AccessToken) == 0 { + return ErrAccessTokenNotSet + } + if len(cfg.InternalRoomID) == 0 { + return ErrInternalRoomID + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.ServerURL) > 0 { + cfg.ServerURL = override.ServerURL + } + if len(override.AccessToken) > 0 { + cfg.AccessToken = override.AccessToken + } + if len(override.InternalRoomID) > 0 { + cfg.InternalRoomID = override.InternalRoomID + } +} + +// AlertProvider is the configuration necessary for sending an alert using Matrix +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"` } -// 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.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, ) @@ -166,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) Config { - if provider.Overrides != nil { - for _, override := range provider.Overrides { - if group == override.Group { - return override.Config - } - } - } - return provider.Config -} - func randStringBytes(n int) string { // All the compatible characters to use in a transaction ID const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -193,3 +214,30 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/matrix/matrix_test.go b/alerting/provider/matrix/matrix_test.go index c1255c65..9827220a 100644 --- a/alerting/provider/matrix/matrix_test.go +++ b/alerting/provider/matrix/matrix_test.go @@ -18,7 +18,7 @@ func TestAlertProvider_IsValid(t *testing.T) { InternalRoomID: "", }, } - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{ @@ -27,7 +27,7 @@ func TestAlertProvider_IsValid(t *testing.T) { InternalRoomID: "!a:example.com", }, } - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } validProviderWithHomeserver := AlertProvider{ @@ -37,7 +37,7 @@ func TestAlertProvider_IsValid(t *testing.T) { InternalRoomID: "!a:example.com", }, } - if !validProviderWithHomeserver.IsValid() { + if !validProviderWithHomeserver.Validate() { t.Error("provider with homeserver should've been valid") } } @@ -54,7 +54,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -68,7 +68,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -87,7 +87,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/mattermost/mattermost.go b/alerting/provider/mattermost/mattermost.go index 6b74596c..94f77592 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,11 +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 cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() + } + 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 { - 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"` @@ -23,44 +55,39 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - WebhookURL string `yaml:"webhook-url"` - Channel string `yaml:"channel,omitempty"` - ClientConfig *client.Config `yaml:"client,omitempty"` -} - // Override is a case under which the default integration is overridden type Override struct { 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,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/mattermost/mattermost_test.go b/alerting/provider/mattermost/mattermost_test.go index 7e7297a9..e4ee5a3c 100644 --- a/alerting/provider/mattermost/mattermost_test.go +++ b/alerting/provider/mattermost/mattermost_test.go @@ -13,11 +13,11 @@ import ( func TestAlertProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{WebhookURL: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{WebhookURL: "http://example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -32,7 +32,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } @@ -44,7 +44,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideWebHookUrl.IsValid() { + if providerWithInvalidOverrideWebHookUrl.Validate() { t.Error("provider WebHookURL shouldn't have been valid") } @@ -57,7 +57,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/messagebird/messagebird.go b/alerting/provider/messagebird/messagebird.go index 9c507651..db153757 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,41 +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 { - 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"` -} - type Config struct { AccessKey string `yaml:"access-key"` Originator string `yaml:"originator"` Recipients string `yaml:"recipients"` } -// 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 +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"` +} + +// 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(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 @@ -64,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()) @@ -72,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 @@ -83,3 +118,21 @@ 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(alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle alert overrides + if len(alert.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/messagebird/messagebird_test.go b/alerting/provider/messagebird/messagebird_test.go index d9341144..4a2d3add 100644 --- a/alerting/provider/messagebird/messagebird_test.go +++ b/alerting/provider/messagebird/messagebird_test.go @@ -13,7 +13,7 @@ import ( func TestMessagebirdAlertProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{ @@ -23,7 +23,7 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) { Recipients: "1", }, } - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/ntfy/ntfy.go b/alerting/provider/ntfy/ntfy.go index d4bdae92..7229755f 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,16 +22,12 @@ const ( TokenPrefix = "tk_" ) -// AlertProvider is the configuration necessary for sending an alert using Slack -type AlertProvider struct { - 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"` -} +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"` @@ -42,63 +40,113 @@ type Config struct { 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"` + + // 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"` } -// 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) @@ -124,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" @@ -147,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 } @@ -163,55 +211,29 @@ 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 -} - -func (provider *AlertProvider) getTopic(override *Override) string { - if override != nil && len(override.Topic) > 0 { - return override.Topic + // Handle alert overrides + if len(alert.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) } - return provider.Topic -} - -func (provider *AlertProvider) getURL(override *Override) string { - if override != nil && len(override.URL) > 0 { - return override.URL + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err } - 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 + return &cfg, nil } diff --git a/alerting/provider/ntfy/ntfy_test.go b/alerting/provider/ntfy/ntfy_test.go index edbee0e3..a68bec27 100644 --- a/alerting/provider/ntfy/ntfy_test.go +++ b/alerting/provider/ntfy/ntfy_test.go @@ -85,8 +85,8 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) { } 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 scenario.provider.Validate() != scenario.expected { + t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.Validate()) } }) } diff --git a/alerting/provider/opsgenie/opsgenie.go b/alerting/provider/opsgenie/opsgenie.go index 1a21da6c..89753656 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,18 +13,16 @@ 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 { - 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"` -} +var ( + ErrAPIKeyNotSet = errors.New("api-key not set") +) type Config struct { // APIKey to use for @@ -55,21 +54,72 @@ type Config struct { Tags []string `yaml:"tags"` } -// IsValid returns whether the provider's configuration is valid -func (provider *AlertProvider) IsValid() bool { - return len(provider.APIKey) > 0 +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"` +} + +// 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(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 } @@ -79,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) @@ -106,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 @@ -119,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()) @@ -162,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, } } @@ -178,43 +228,29 @@ 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(alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle alert overrides + if len(alert.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + 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 f38c9b27..58e6b0f2 100644 --- a/alerting/provider/opsgenie/opsgenie_test.go +++ b/alerting/provider/opsgenie/opsgenie_test.go @@ -13,11 +13,11 @@ import ( func TestAlertProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{APIKey: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{APIKey: "00000000-0000-0000-0000-000000000000"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/pagerduty/pagerduty.go b/alerting/provider/pagerduty/pagerduty.go index eb384aeb..fbad227b 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 { - 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"` @@ -28,36 +52,36 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - IntegrationKey string `yaml:"integration-key"` -} - // Override is a case under which the default integration is overridden type Override struct { 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 @@ -104,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()) @@ -116,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{ @@ -128,23 +152,38 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + 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 f991ba84..0c67c91e 100644 --- a/alerting/provider/pagerduty/pagerduty_test.go +++ b/alerting/provider/pagerduty/pagerduty_test.go @@ -13,11 +13,11 @@ import ( func TestAlertProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{IntegrationKey: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{IntegrationKey: "00000000000000000000000000000000"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -31,7 +31,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideIntegrationKey := AlertProvider{ @@ -42,7 +42,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideIntegrationKey.IsValid() { + if providerWithInvalidOverrideIntegrationKey.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -53,7 +53,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index f9386b94..7635addd 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -29,14 +29,19 @@ import ( // AlertProvider is the interface that each provider should implement type AlertProvider interface { - // IsValid returns whether the provider's configuration is valid - IsValid() bool - - // GetDefaultAlert returns the provider's default alert configuration - GetDefaultAlert() *alert.Alert + // 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 +} + +type Config[T any] interface { + Validate() error + Merge(override *T) } // ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline @@ -62,7 +67,7 @@ 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) @@ -85,4 +90,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/pushover/pushover.go b/alerting/provider/pushover/pushover.go index 452ed66a..9e197225 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,13 +19,11 @@ const ( defaultPriority = 0 ) -// AlertProvider is the configuration necessary for sending an alert using Pushover -type AlertProvider struct { - 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"` -} +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 @@ -50,21 +50,67 @@ type Config struct { Sound string `yaml:"sound,omitempty"` } -// IsValid returns whether the provider's configuration is valid -func (provider *AlertProvider) IsValid() bool { - if provider.Priority == 0 { - provider.Priority = defaultPriority +func (cfg *Config) Validate() error { + if cfg.Priority == 0 { + cfg.Priority = defaultPriority } - if provider.ResolvedPriority == 0 { - provider.ResolvedPriority = defaultPriority + if cfg.ResolvedPriority == 0 { + cfg.ResolvedPriority = defaultPriority } - return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2 && provider.ResolvedPriority >= -2 && provider.ResolvedPriority <= 2 + 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"` +} + +// 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 @@ -92,38 +138,47 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/pushover/pushover_test.go b/alerting/provider/pushover/pushover_test.go index 581bd3de..3e7379c2 100644 --- a/alerting/provider/pushover/pushover_test.go +++ b/alerting/provider/pushover/pushover_test.go @@ -13,7 +13,7 @@ import ( func TestPushoverAlertProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{ @@ -25,7 +25,7 @@ func TestPushoverAlertProvider_IsValid(t *testing.T) { ResolvedPriority: 1, }, } - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -38,7 +38,7 @@ func TestPushoverAlertProvider_IsInvalid(t *testing.T) { Priority: 5, }, } - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider should've been invalid") } } diff --git a/alerting/provider/slack/slack.go b/alerting/provider/slack/slack.go index eaebf7d1..2f69acf7 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,11 +11,34 @@ 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 { - 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"` @@ -23,34 +47,34 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - WebhookURL string `yaml:"webhook-url"` // Slack webhook URL -} - // Override is a case under which the default integration is overridden type Override struct { 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 } @@ -132,19 +156,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/slack/slack_test.go b/alerting/provider/slack/slack_test.go index 40a7e4a4..05c24a88 100644 --- a/alerting/provider/slack/slack_test.go +++ b/alerting/provider/slack/slack_test.go @@ -13,11 +13,11 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{WebhookURL: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{WebhookURL: "https://example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -31,7 +31,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -42,7 +42,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -54,7 +54,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/teams/teams.go b/alerting/provider/teams/teams.go index ce0180ff..407831ee 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,25 +11,53 @@ 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 Teams -type AlertProvider struct { - 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"` -} +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 cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() + } + if len(cfg.WebhookURL) == 0 { + return ErrWebhookURLNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() + } + if len(override.WebhookURL) > 0 { + cfg.WebhookURL = override.WebhookURL + } + if len(override.Title) > 0 { + cfg.Title = override.Title + } +} + +// AlertProvider is the configuration necessary for sending an alert using Teams +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` } // Override is a case under which the default integration is overridden @@ -37,29 +66,33 @@ type Override struct { 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 } @@ -86,7 +119,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) @@ -113,7 +146,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 { @@ -129,19 +162,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/teams/teams_test.go b/alerting/provider/teams/teams_test.go index 718486b9..a1f105c7 100644 --- a/alerting/provider/teams/teams_test.go +++ b/alerting/provider/teams/teams_test.go @@ -13,11 +13,11 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{WebhookURL: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{WebhookURL: "http://example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -31,7 +31,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -42,7 +42,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -54,7 +54,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/teamsworkflows/teamsworkflows.go b/alerting/provider/teamsworkflows/teamsworkflows.go index a52bc39f..11de6ac5 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,11 +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"` + 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 { - 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"` @@ -23,35 +51,34 @@ type AlertProvider struct { Overrides []Override `yaml:"overrides,omitempty"` } -type Config struct { - WebhookURL string `yaml:"webhook-url"` - Title string `yaml:"title,omitempty"` // Title of the message that will be sent -} - // Override is a case under which the default integration is overridden type Override struct { 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 } @@ -108,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 { @@ -121,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 @@ -191,19 +218,34 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/teamsworkflows/teamsworkflows_test.go b/alerting/provider/teamsworkflows/teamsworkflows_test.go index cc53e2da..03259ff7 100644 --- a/alerting/provider/teamsworkflows/teamsworkflows_test.go +++ b/alerting/provider/teamsworkflows/teamsworkflows_test.go @@ -13,11 +13,11 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{Config: Config{WebhookURL: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{Config: Config{WebhookURL: "http://example.com"}} - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } @@ -31,7 +31,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideGroup.IsValid() { + if providerWithInvalidOverrideGroup.Validate() { t.Error("provider Group shouldn't have been valid") } providerWithInvalidOverrideTo := AlertProvider{ @@ -42,7 +42,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if providerWithInvalidOverrideTo.IsValid() { + if providerWithInvalidOverrideTo.Validate() { t.Error("provider integration key shouldn't have been valid") } providerWithValidOverride := AlertProvider{ @@ -54,7 +54,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { }, }, } - if !providerWithValidOverride.IsValid() { + if !providerWithValidOverride.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/telegram/telegram.go b/alerting/provider/telegram/telegram.go index 73e690c6..0db47cec 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,69 +11,100 @@ 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" -// AlertProvider is the configuration necessary for sending an alert using Telegram -type AlertProvider struct { - 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 []*Override `yaml:"overrides,omitempty"` -} +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"` + 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"` } +func (cfg *Config) Validate() error { + if cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() + } + 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 cfg.ClientConfig == nil { + cfg.ClientConfig = client.GetDefaultConfig() + } + 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 { + 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 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"` + 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 } @@ -84,15 +116,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"` @@ -100,7 +123,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) @@ -127,23 +150,41 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/telegram/telegram_test.go b/alerting/provider/telegram/telegram_test.go index 7701bfe1..7450a5f5 100644 --- a/alerting/provider/telegram/telegram_test.go +++ b/alerting/provider/telegram/telegram_test.go @@ -14,7 +14,7 @@ import ( func TestAlertDefaultProvider_IsValid(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) { invalidProvider := AlertProvider{Config: Config{Token: "", ID: ""}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } }) @@ -23,7 +23,7 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) { if validProvider.ClientConfig != nil { t.Error("provider client config should have been nil prior to IsValid() being executed") } - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } if validProvider.ClientConfig == nil { @@ -35,22 +35,22 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValidWithOverrides(t *testing.T) { t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) { invalidProvider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } }) t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) { - invalidProvider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{group: "group1", Config: Config{Token: "token", ID: "id"}}, {group: "group1", Config: Config{ID: "id2"}}}} - if invalidProvider.IsValid() { + invalidProvider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group1", Config: Config{Token: "token", ID: "id"}}, {Group: "group1", Config: Config{ID: "id2"}}}} + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } }) t.Run("valid-provider", func(t *testing.T) { - validProvider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{group: "group", Config: Config{Token: "token", ID: "id"}}}} + validProvider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{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() { + if !validProvider.Validate() { t.Error("provider should've been valid") } if validProvider.ClientConfig == nil { @@ -61,7 +61,7 @@ func TestAlertProvider_IsValidWithOverrides(t *testing.T) { func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) { t.Run("get-token-with-override", func(t *testing.T) { - provider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{group: "group", Config: Config{Token: "overrideToken", ID: "overrideID"}}}} + provider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "overrideToken", ID: "overrideID"}}}} token := provider.getTokenForGroup("group") if token != "overrideToken" { t.Error("token should have been 'overrideToken'") @@ -72,7 +72,7 @@ func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) { } }) t.Run("get-default-token-with-overridden-id", func(t *testing.T) { - provider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{group: "group", Config: Config{ID: "overrideID"}}}} + provider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{ID: "overrideID"}}}} token := provider.getTokenForGroup("group") if token != provider.Token { t.Error("token should have been the default token") @@ -83,7 +83,7 @@ func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) { } }) t.Run("get-default-token-with-overridden-token", func(t *testing.T) { - provider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{group: "group", Config: Config{Token: "overrideToken"}}}} + provider := AlertProvider{Config: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "overrideToken"}}}} token := provider.getTokenForGroup("group") if token != "overrideToken" { t.Error("token should have been 'overrideToken'") diff --git a/alerting/provider/twilio/twilio.go b/alerting/provider/twilio/twilio.go index 01e69c06..7cb30920 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,15 +12,15 @@ 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 { - 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"` -} +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"` @@ -28,20 +29,63 @@ type Config struct { To string `yaml:"to"` } -// 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 +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"` +} + +// 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 @@ -55,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()) @@ -63,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() } @@ -73,3 +117,21 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/twilio/twilio_test.go b/alerting/provider/twilio/twilio_test.go index d770e0e2..d3b817b5 100644 --- a/alerting/provider/twilio/twilio_test.go +++ b/alerting/provider/twilio/twilio_test.go @@ -9,7 +9,7 @@ import ( func TestTwilioAlertProvider_IsValid(t *testing.T) { invalidProvider := AlertProvider{} - if invalidProvider.IsValid() { + if invalidProvider.Validate() { t.Error("provider shouldn't have been valid") } validProvider := AlertProvider{ @@ -20,7 +20,7 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) { To: "1", }, } - if !validProvider.IsValid() { + if !validProvider.Validate() { t.Error("provider should've been valid") } } diff --git a/alerting/provider/zulip/zulip.go b/alerting/provider/zulip/zulip.go index c4f1ccf7..bd491775 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,18 +11,16 @@ 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 Zulip -type AlertProvider struct { - 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"` -} +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 string `yaml:"bot-email"` // Email of the bot user @@ -30,86 +29,81 @@ type Config struct { 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 { + 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"` } -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) @@ -124,7 +118,62 @@ 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.Override) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.Override, &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/alerting/provider/zulip/zulip_test.go b/alerting/provider/zulip/zulip_test.go index 3d481ecd..8f59e21d 100644 --- a/alerting/provider/zulip/zulip_test.go +++ b/alerting/provider/zulip/zulip_test.go @@ -82,7 +82,7 @@ func TestAlertProvider_IsValid(t *testing.T) { } for _, tc := range testCase { t.Run(tc.name, func(t *testing.T) { - if tc.alertProvider.IsValid() != tc.expected { + if tc.alertProvider.Validate() != tc.expected { t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected) } }) @@ -211,7 +211,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { } for _, tc := range testCase { t.Run(tc.name, func(t *testing.T) { - if tc.alertProvider.IsValid() != tc.expected { + if tc.alertProvider.Validate() != tc.expected { t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected) } }) diff --git a/config/config.go b/config/config.go index 210752f4..b15485ab 100644 --- a/config/config.go +++ b/config/config.go @@ -421,7 +421,7 @@ 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 { @@ -443,7 +443,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi } 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/watchdog/alerting_test.go b/watchdog/alerting_test.go index ee7e2e5a..9ba8cb47 100644 --- a/watchdog/alerting_test.go +++ b/watchdog/alerting_test.go @@ -272,7 +272,7 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { AlertType: alert.TypeDiscord, AlertingConfig: &alerting.Config{ Discord: &discord.AlertProvider{ - Config: discord.Config{ + DefaultConfig: discord.Config{ WebhookURL: "https://example.com", }, },