Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(alerting): Add AWS SES Alerting Provider #579

Merged
merged 15 commits into from
Oct 26, 2023
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Storage](#storage)
- [Client configuration](#client-configuration)
- [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring GitHub alerts](#configuring-github-alerts)
Expand Down Expand Up @@ -1041,6 +1042,44 @@ endpoints:
```


#### Configuring AWS SES alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.aws-ses` | Settings for alerts of type `aws-ses` | `{}` |
| `alerting.aws-ses.access-key-id` | AWS Access Key ID | Optional `""` |
| `alerting.aws-ses.secret-access-key` | AWS Secret Access Key | Optional `""` |
| `alerting.aws-ses.region` | AWS Region | Required `""` |
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |

```yaml
alerting:
aws-ses:
access-key-id: "..."
secret-access-key: "..."
region: "us-east-1"
from: "[email protected]"
to: "[email protected]"
TwiN marked this conversation as resolved.
Show resolved Hide resolved

endpoints:
- name: website
interval: 30s
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: aws-ses
failure-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```

Make sure you have the ability to use `ses:SendEmail`.


#### Configuring custom alerts
| Parameter | Description | Default |
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
Expand Down
3 changes: 3 additions & 0 deletions alerting/alert/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ package alert
type Type string

const (
// TypeAWSSES is the Type for the awsses alerting provider
TypeAWSSES Type = "aws-ses"

// TypeCustom is the Type for the custom alerting provider
TypeCustom Type = "custom"

Expand Down
7 changes: 6 additions & 1 deletion alerting/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
Expand All @@ -28,6 +29,9 @@ import (

// Config is the configuration for alerting providers
type Config struct {
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`

// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`

Expand Down Expand Up @@ -85,7 +89,8 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
if strings.ToLower(field.Name) == string(alertType) {
tag := strings.Split(field.Tag.Get("yaml"), ",")[0]
TwiN marked this conversation as resolved.
Show resolved Hide resolved
if tag == string(alertType) {
fieldValue := reflect.ValueOf(config).Elem().Field(i)
if fieldValue.IsNil() {
return nil
Expand Down
167 changes: 167 additions & 0 deletions alerting/provider/awsses/awsses.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package awsses

import (
"fmt"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"strings"
)

const (
CharSet = "UTF-8"
)

// AlertProvider is the configuration necessary for sending an alert using PagerDuty
beschoenen marked this conversation as resolved.
Show resolved Hide resolved
type AlertProvider struct {
AccessKeyID string `yaml:"access-key-id"`
SecretAccessKey string `yaml:"secret-access-key"`
Region string `yaml:"region"`

From string `yaml:"from"`
To string `yaml:"to"`

// 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"`
To string `yaml:"to"`
}

// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
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
}
registeredGroups[override.Group] = true
}
}

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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would (len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) be acceptable if len(provider.From) > 0 && len(provider.To) > 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AWS access key and secret are optional variables if you use IAM authentication. So they both should either be empty or filled in.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, should the condition check for IAM auth if the keys aren't provided?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IAM authentication is implicit on resources within AWS, and doesn't really need to be checked, if that's what you mean.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see, I was interpretting it as the aws profile config but I suppose it's implicit if you deploy to aws.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment that clarifies this?

i.e. `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 auth

}

// Send an alert using the provider
//
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
beschoenen marked this conversation as resolved.
Show resolved Hide resolved
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
sess, err := provider.CreateSesSession()
if err != nil {
return err
}
svc := ses.New(sess)

subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
emails := strings.Split(provider.getToForGroup(endpoint.Group), ",")

beschoenen marked this conversation as resolved.
Show resolved Hide resolved
input := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: aws.StringSlice(emails),
},
Message: &ses.Message{
Body: &ses.Body{
Text: &ses.Content{
Charset: aws.String(CharSet),
Data: aws.String(body),
},
},
Subject: &ses.Content{
Charset: aws.String(CharSet),
Data: aws.String(subject),
},
},
Source: aws.String(provider.From),
}

_, err = svc.SendEmail(input)

beschoenen marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
default:
fmt.Println(aerr.Error())
}
} else {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
fmt.Println(err.Error())
}

return err
}
return nil
}

// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
var subject, message, results string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + "\n\nCondition results:\n" + results
}

// 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) CreateSesSession() (*session.Session, error) {
config := &aws.Config{
Region: aws.String(provider.Region),
}

if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
}

return session.NewSession(config)
}
Loading