Skip to content

Commit 79c9f24

Browse files
authored
feat(alerting): Implement alert-level provider overrides (#929)
* feat(alerting): Implement alert-level provider overrides Fixes #96 * Fix tests * Add missing test cases for alerting providers * feat(alerting): Implement alert-level overrides on all providers * chore: Add config.yaml to .gitignore * fix typo in discord provider * test: Start fixing tests for alerting providers * test: Fix GitLab tests * Fix all tests * test: Improve coverage * test: Improve coverage * Rename override to provider-override * docs: Mention new provider-override config * test: Improve coverage * test: Improve coverage * chore: Rename Alert.OverrideAsBytes to Alert.ProviderOverrideAsBytes
1 parent be9ae6f commit 79c9f24

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+4509
-1995
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ node_modules
1717
*.db-shm
1818
*.db-wal
1919
gatus
20-
config/config.yml
20+
config/config.yml
21+
config.yaml

README.md

Lines changed: 80 additions & 71 deletions
Large diffs are not rendered by default.

alerting/alert/alert.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import (
66
"errors"
77
"strconv"
88
"strings"
9+
10+
"github.com/TwiN/logr"
11+
"gopkg.in/yaml.v3"
912
)
1013

1114
var (
@@ -36,13 +39,17 @@ type Alert struct {
3639
//
3740
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
3841
// or not for provider.ParseWithDefaultAlert to work.
39-
Description *string `yaml:"description"`
42+
Description *string `yaml:"description,omitempty"`
4043

4144
// SendOnResolved defines whether to send a second notification when the issue has been resolved
4245
//
4346
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
4447
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
45-
SendOnResolved *bool `yaml:"send-on-resolved"`
48+
SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
49+
50+
// ProviderOverride is an optional field that can be used to override the provider's configuration
51+
// It is freeform so that it can be used for any provider-specific configuration.
52+
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
4653

4754
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
4855
// ongoing/triggered incidents
@@ -111,3 +118,11 @@ func (alert *Alert) Checksum() string {
111118
)
112119
return hex.EncodeToString(hash.Sum(nil))
113120
}
121+
122+
func (alert *Alert) ProviderOverrideAsBytes() []byte {
123+
yamlBytes, err := yaml.Marshal(alert.ProviderOverride)
124+
if err != nil {
125+
logr.Warnf("[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v", alert.Type, err)
126+
}
127+
return yamlBytes
128+
}

alerting/provider/awsses/awsses.go

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,73 @@
11
package awsses
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67

78
"github.com/TwiN/gatus/v5/alerting/alert"
89
"github.com/TwiN/gatus/v5/config/endpoint"
10+
"github.com/TwiN/logr"
911
"github.com/aws/aws-sdk-go/aws"
1012
"github.com/aws/aws-sdk-go/aws/awserr"
1113
"github.com/aws/aws-sdk-go/aws/credentials"
1214
"github.com/aws/aws-sdk-go/aws/session"
1315
"github.com/aws/aws-sdk-go/service/ses"
16+
"gopkg.in/yaml.v3"
1417
)
1518

1619
const (
1720
CharSet = "UTF-8"
1821
)
1922

20-
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
21-
type AlertProvider struct {
23+
var (
24+
ErrDuplicateGroupOverride = errors.New("duplicate group override")
25+
ErrMissingFromOrToFields = errors.New("from and to fields are required")
26+
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
27+
)
28+
29+
type Config struct {
2230
AccessKeyID string `yaml:"access-key-id"`
2331
SecretAccessKey string `yaml:"secret-access-key"`
2432
Region string `yaml:"region"`
2533

2634
From string `yaml:"from"`
2735
To string `yaml:"to"`
36+
}
37+
38+
func (cfg *Config) Validate() error {
39+
if len(cfg.From) == 0 || len(cfg.To) == 0 {
40+
return ErrMissingFromOrToFields
41+
}
42+
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
43+
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
44+
// otherwise if neither are specified, then we'll fall back on IAM authentication.
45+
return ErrInvalidAWSAuthConfig
46+
}
47+
return nil
48+
}
49+
50+
func (cfg *Config) Merge(override *Config) {
51+
if len(override.AccessKeyID) > 0 {
52+
cfg.AccessKeyID = override.AccessKeyID
53+
}
54+
if len(override.SecretAccessKey) > 0 {
55+
cfg.SecretAccessKey = override.SecretAccessKey
56+
}
57+
if len(override.Region) > 0 {
58+
cfg.Region = override.Region
59+
}
60+
if len(override.From) > 0 {
61+
cfg.From = override.From
62+
}
63+
if len(override.To) > 0 {
64+
cfg.To = override.To
65+
}
66+
}
67+
68+
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
69+
type AlertProvider struct {
70+
DefaultConfig Config `yaml:",inline"`
2871

2972
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
3073
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -35,36 +78,37 @@ type AlertProvider struct {
3578

3679
// Override is a case under which the default integration is overridden
3780
type Override struct {
38-
Group string `yaml:"group"`
39-
To string `yaml:"to"`
81+
Group string `yaml:"group"`
82+
Config `yaml:",inline"`
4083
}
4184

42-
// IsValid returns whether the provider's configuration is valid
43-
func (provider *AlertProvider) IsValid() bool {
85+
// Validate the provider's configuration
86+
func (provider *AlertProvider) Validate() error {
4487
registeredGroups := make(map[string]bool)
4588
if provider.Overrides != nil {
4689
for _, override := range provider.Overrides {
4790
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
48-
return false
91+
return ErrDuplicateGroupOverride
4992
}
5093
registeredGroups[override.Group] = true
5194
}
5295
}
53-
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
54-
// otherwise if neither are specified, then we'll fall back on IAM authentication.
55-
return len(provider.From) > 0 && len(provider.To) > 0 &&
56-
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
96+
return provider.DefaultConfig.Validate()
5797
}
5898

5999
// Send an alert using the provider
60100
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
61-
sess, err := provider.createSession()
101+
cfg, err := provider.GetConfig(ep.Group, alert)
62102
if err != nil {
63103
return err
64104
}
65-
svc := ses.New(sess)
105+
awsSession, err := provider.createSession(cfg)
106+
if err != nil {
107+
return err
108+
}
109+
svc := ses.New(awsSession)
66110
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
67-
emails := strings.Split(provider.getToForGroup(ep.Group), ",")
111+
emails := strings.Split(cfg.To, ",")
68112

69113
input := &ses.SendEmailInput{
70114
Destination: &ses.Destination{
@@ -82,33 +126,41 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
82126
Data: aws.String(subject),
83127
},
84128
},
85-
Source: aws.String(provider.From),
129+
Source: aws.String(cfg.From),
86130
}
87-
_, err = svc.SendEmail(input)
88-
89-
if err != nil {
131+
if _, err = svc.SendEmail(input); err != nil {
90132
if aerr, ok := err.(awserr.Error); ok {
91133
switch aerr.Code() {
92134
case ses.ErrCodeMessageRejected:
93-
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
135+
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
94136
case ses.ErrCodeMailFromDomainNotVerifiedException:
95-
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
137+
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
96138
case ses.ErrCodeConfigurationSetDoesNotExistException:
97-
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
139+
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
98140
default:
99-
fmt.Println(aerr.Error())
141+
logr.Error(aerr.Error())
100142
}
101143
} else {
102144
// Print the error, cast err to awserr.Error to get the Code and
103145
// Message from an error.
104-
fmt.Println(err.Error())
146+
logr.Error(err.Error())
105147
}
106148

107149
return err
108150
}
109151
return nil
110152
}
111153

154+
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
155+
awsConfig := &aws.Config{
156+
Region: aws.String(cfg.Region),
157+
}
158+
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
159+
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
160+
}
161+
return session.NewSession(awsConfig)
162+
}
163+
112164
// buildMessageSubjectAndBody builds the message subject and body
113165
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
114166
var subject, message string
@@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
139191
return subject, message + description + formattedConditionResults
140192
}
141193

142-
// getToForGroup returns the appropriate email integration to for a given group
143-
func (provider *AlertProvider) getToForGroup(group string) string {
194+
// GetDefaultAlert returns the provider's default alert configuration
195+
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
196+
return provider.DefaultAlert
197+
}
198+
199+
// GetConfig returns the configuration for the provider with the overrides applied
200+
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
201+
cfg := provider.DefaultConfig
202+
// Handle group overrides
144203
if provider.Overrides != nil {
145204
for _, override := range provider.Overrides {
146205
if group == override.Group {
147-
return override.To
206+
cfg.Merge(&override.Config)
207+
break
148208
}
149209
}
150210
}
151-
return provider.To
152-
}
153-
154-
// GetDefaultAlert returns the provider's default alert configuration
155-
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
156-
return provider.DefaultAlert
211+
// Handle alert overrides
212+
if len(alert.ProviderOverride) != 0 {
213+
overrideConfig := Config{}
214+
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
215+
return nil, err
216+
}
217+
cfg.Merge(&overrideConfig)
218+
}
219+
// Validate the configuration
220+
err := cfg.Validate()
221+
return &cfg, err
157222
}
158223

159-
func (provider *AlertProvider) createSession() (*session.Session, error) {
160-
config := &aws.Config{
161-
Region: aws.String(provider.Region),
162-
}
163-
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
164-
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
165-
}
166-
return session.NewSession(config)
224+
// ValidateOverrides validates the alert's provider override and, if present, the group override
225+
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
226+
_, err := provider.GetConfig(group, alert)
227+
return err
167228
}

0 commit comments

Comments
 (0)