From d29f06d5b7f9c1834be9fb2e6e2e13b005d1c0b5 Mon Sep 17 00:00:00 2001 From: Kunal Gupta <39487888+iamKunalGupta@users.noreply.github.com> Date: Wed, 13 Mar 2024 01:10:15 +0530 Subject: [PATCH] feat(alerting): add email sender (#1433) New ENV Vars: - `PEERDB_ALERTING_EMAIL_SENDER_SOURCE_EMAIL` - `PEERDB_ALERTING_EMAIL_SENDER_CONFIGURATION_SET` - `PEERDB_ALERTING_EMAIL_SENDER_REGION` - `PEERDB_ALERTING_EMAIL_SENDER_REPLY_TO_ADDRESSES` - [x] test aws cross region sending - config set is per region, have added env for setting a specific region UI: image --------- Co-authored-by: Amogh-Bharadwaj --- flow/alerting/alerting.go | 161 ++++++++----- flow/alerting/email_alert_sender.go | 104 ++++++++ flow/alerting/interface.go | 9 + flow/alerting/slack_alert_sender.go | 21 +- flow/alerting/types.go | 8 + flow/go.mod | 1 + flow/go.sum | 2 + flow/peerdbenv/config.go | 17 ++ flow/shared/aws_common/config.go | 21 ++ flow/shared/telemetry/sns_message_sender.go | 12 +- .../V21__alert_constraint_update.sql | 6 + .../V22__alert_column_add_config_id.sql | 2 + ui/app/alert-config/new.tsx | 226 +++++++++++++----- ui/app/alert-config/page.tsx | 84 +++++-- ui/app/alert-config/validation.ts | 86 +++++-- ui/app/api/alert-config/route.ts | 1 + ui/components/AlertDropdown.tsx | 23 +- ui/public/images/email.png | Bin 0 -> 18231 bytes ui/public/images/slack.png | Bin 6109 -> 6999 bytes ui/public/images/slack_full.png | Bin 0 -> 6109 bytes 20 files changed, 592 insertions(+), 192 deletions(-) create mode 100644 flow/alerting/email_alert_sender.go create mode 100644 flow/alerting/interface.go create mode 100644 flow/alerting/types.go create mode 100644 flow/shared/aws_common/config.go create mode 100644 nexus/catalog/migrations/V21__alert_constraint_update.sql create mode 100644 nexus/catalog/migrations/V22__alert_column_add_config_id.sql create mode 100644 ui/public/images/email.png create mode 100644 ui/public/images/slack_full.png diff --git a/flow/alerting/alerting.go b/flow/alerting/alerting.go index 93cb946f78..acdbe6c290 100644 --- a/flow/alerting/alerting.go +++ b/flow/alerting/alerting.go @@ -3,8 +3,10 @@ package alerting import ( "context" "encoding/json" + "errors" "fmt" "log/slog" + "strings" "time" "github.com/jackc/pgx/v5" @@ -23,32 +25,67 @@ type Alerter struct { telemetrySender telemetry.Sender } -func (a *Alerter) registerSendersFromPool(ctx context.Context) ([]*slackAlertSender, error) { +type AlertSenderConfig struct { + Id int64 + Sender AlertSender +} + +func (a *Alerter) registerSendersFromPool(ctx context.Context) ([]AlertSenderConfig, error) { rows, err := a.catalogPool.Query(ctx, - "SELECT service_type,service_config FROM peerdb_stats.alerting_config") + "SELECT id,service_type,service_config FROM peerdb_stats.alerting_config") if err != nil { return nil, fmt.Errorf("failed to read alerter config from catalog: %w", err) } - var slackAlertSenders []*slackAlertSender - var serviceType, serviceConfig string - _, err = pgx.ForEachRow(rows, []any{&serviceType, &serviceConfig}, func() error { + var alertSenderConfigs []AlertSenderConfig + var serviceType ServiceType + var serviceConfig string + var id int64 + _, err = pgx.ForEachRow(rows, []any{&id, &serviceType, &serviceConfig}, func() error { switch serviceType { - case "slack": + case SLACK: var slackServiceConfig slackAlertConfig err = json.Unmarshal([]byte(serviceConfig), &slackServiceConfig) if err != nil { - return fmt.Errorf("failed to unmarshal Slack service config: %w", err) + return fmt.Errorf("failed to unmarshal %s service config: %w", serviceType, err) + } + + alertSenderConfigs = append(alertSenderConfigs, AlertSenderConfig{Id: id, Sender: newSlackAlertSender(&slackServiceConfig)}) + case EMAIL: + var replyToAddresses []string + if replyToEnvString := strings.TrimSpace( + peerdbenv.PeerDBAlertingEmailSenderReplyToAddresses()); replyToEnvString != "" { + replyToAddresses = strings.Split(replyToEnvString, ",") + } + emailServiceConfig := EmailAlertSenderConfig{ + sourceEmail: peerdbenv.PeerDBAlertingEmailSenderSourceEmail(), + configurationSetName: peerdbenv.PeerDBAlertingEmailSenderConfigurationSet(), + replyToAddresses: replyToAddresses, + } + if emailServiceConfig.sourceEmail == "" { + return errors.New("missing sourceEmail for Email alerting service") + } + err = json.Unmarshal([]byte(serviceConfig), &emailServiceConfig) + if err != nil { + return fmt.Errorf("failed to unmarshal %s service config: %w", serviceType, err) + } + var region *string + if envRegion := peerdbenv.PeerDBAlertingEmailSenderRegion(); envRegion != "" { + region = &envRegion } - slackAlertSenders = append(slackAlertSenders, newSlackAlertSender(&slackServiceConfig)) + alertSender, alertSenderErr := NewEmailAlertSenderWithNewClient(ctx, region, &emailServiceConfig) + if alertSenderErr != nil { + return fmt.Errorf("failed to initialize email alerter: %w", alertSenderErr) + } + alertSenderConfigs = append(alertSenderConfigs, AlertSenderConfig{Id: id, Sender: alertSender}) default: return fmt.Errorf("unknown service type: %s", serviceType) } return nil }) - return slackAlertSenders, nil + return alertSenderConfigs, nil } // doesn't take care of closing pool, needs to be done externally. @@ -75,9 +112,9 @@ func NewAlerter(ctx context.Context, catalogPool *pgxpool.Pool) *Alerter { } func (a *Alerter) AlertIfSlotLag(ctx context.Context, peerName string, slotInfo *protos.SlotInfo) { - slackAlertSenders, err := a.registerSendersFromPool(ctx) + alertSenderConfigs, err := a.registerSendersFromPool(ctx) if err != nil { - logger.LoggerFromCtx(ctx).Warn("failed to set Slack senders", slog.Any("error", err)) + logger.LoggerFromCtx(ctx).Warn("failed to set alert senders", slog.Any("error", err)) return } @@ -89,29 +126,30 @@ func (a *Alerter) AlertIfSlotLag(ctx context.Context, peerName string, slotInfo defaultSlotLagMBAlertThreshold := dynamicconf.PeerDBSlotLagMBAlertThreshold(ctx) // catalog cannot use default threshold to space alerts properly, use the lowest set threshold instead lowestSlotLagMBAlertThreshold := defaultSlotLagMBAlertThreshold - for _, slackAlertSender := range slackAlertSenders { - if slackAlertSender.slotLagMBAlertThreshold > 0 { - lowestSlotLagMBAlertThreshold = min(lowestSlotLagMBAlertThreshold, slackAlertSender.slotLagMBAlertThreshold) + for _, alertSender := range alertSenderConfigs { + if alertSender.Sender.getSlotLagMBAlertThreshold() > 0 { + lowestSlotLagMBAlertThreshold = min(lowestSlotLagMBAlertThreshold, alertSender.Sender.getSlotLagMBAlertThreshold()) } } - alertKey := peerName + "-slot-lag-threshold-exceeded" + alertKey := fmt.Sprintf("%s Slot Lag Threshold Exceeded for Peer %s", deploymentUIDPrefix, peerName) alertMessageTemplate := fmt.Sprintf("%sSlot `%s` on peer `%s` has exceeded threshold size of %%dMB, "+ - `currently at %.2fMB! - cc: `, deploymentUIDPrefix, slotInfo.SlotName, peerName, slotInfo.LagInMb) - - if slotInfo.LagInMb > float32(lowestSlotLagMBAlertThreshold) && - a.checkAndAddAlertToCatalog(ctx, alertKey, fmt.Sprintf(alertMessageTemplate, lowestSlotLagMBAlertThreshold)) { - for _, slackAlertSender := range slackAlertSenders { - if slackAlertSender.slotLagMBAlertThreshold > 0 { - if slotInfo.LagInMb > float32(slackAlertSender.slotLagMBAlertThreshold) { - a.alertToSlack(ctx, slackAlertSender, alertKey, - fmt.Sprintf(alertMessageTemplate, slackAlertSender.slotLagMBAlertThreshold)) - } - } else { - if slotInfo.LagInMb > float32(defaultSlotLagMBAlertThreshold) { - a.alertToSlack(ctx, slackAlertSender, alertKey, - fmt.Sprintf(alertMessageTemplate, defaultSlotLagMBAlertThreshold)) + `currently at %.2fMB!`, deploymentUIDPrefix, slotInfo.SlotName, peerName, slotInfo.LagInMb) + + if slotInfo.LagInMb > float32(lowestSlotLagMBAlertThreshold) { + for _, alertSenderConfig := range alertSenderConfigs { + if a.checkAndAddAlertToCatalog(ctx, + alertSenderConfig.Id, alertKey, fmt.Sprintf(alertMessageTemplate, lowestSlotLagMBAlertThreshold)) { + if alertSenderConfig.Sender.getSlotLagMBAlertThreshold() > 0 { + if slotInfo.LagInMb > float32(alertSenderConfig.Sender.getSlotLagMBAlertThreshold()) { + a.alertToProvider(ctx, alertSenderConfig, alertKey, + fmt.Sprintf(alertMessageTemplate, alertSenderConfig.Sender.getSlotLagMBAlertThreshold())) + } + } else { + if slotInfo.LagInMb > float32(defaultSlotLagMBAlertThreshold) { + a.alertToProvider(ctx, alertSenderConfig, alertKey, + fmt.Sprintf(alertMessageTemplate, defaultSlotLagMBAlertThreshold)) + } } } } @@ -121,7 +159,7 @@ func (a *Alerter) AlertIfSlotLag(ctx context.Context, peerName string, slotInfo func (a *Alerter) AlertIfOpenConnections(ctx context.Context, peerName string, openConnections *protos.GetOpenConnectionsForUserResult, ) { - slackAlertSenders, err := a.registerSendersFromPool(ctx) + alertSenderConfigs, err := a.registerSendersFromPool(ctx) if err != nil { logger.LoggerFromCtx(ctx).Warn("failed to set Slack senders", slog.Any("error", err)) return @@ -129,44 +167,45 @@ func (a *Alerter) AlertIfOpenConnections(ctx context.Context, peerName string, deploymentUIDPrefix := "" if peerdbenv.PeerDBDeploymentUID() != "" { - deploymentUIDPrefix = fmt.Sprintf("[%s] ", peerdbenv.PeerDBDeploymentUID()) + deploymentUIDPrefix = fmt.Sprintf("[%s] - ", peerdbenv.PeerDBDeploymentUID()) } // same as with slot lag, use lowest threshold for catalog defaultOpenConnectionsThreshold := dynamicconf.PeerDBOpenConnectionsAlertThreshold(ctx) lowestOpenConnectionsThreshold := defaultOpenConnectionsThreshold - for _, slackAlertSender := range slackAlertSenders { - if slackAlertSender.openConnectionsAlertThreshold > 0 { - lowestOpenConnectionsThreshold = min(lowestOpenConnectionsThreshold, slackAlertSender.openConnectionsAlertThreshold) + for _, alertSender := range alertSenderConfigs { + if alertSender.Sender.getOpenConnectionsAlertThreshold() > 0 { + lowestOpenConnectionsThreshold = min(lowestOpenConnectionsThreshold, alertSender.Sender.getOpenConnectionsAlertThreshold()) } } - alertKey := peerName + "-max-open-connections-threshold-exceeded" + alertKey := fmt.Sprintf("%s Max Open Connections Threshold Exceeded for Peer %s", deploymentUIDPrefix, peerName) alertMessageTemplate := fmt.Sprintf("%sOpen connections from PeerDB user `%s` on peer `%s`"+ - ` has exceeded threshold size of %%d connections, currently at %d connections! - cc: `, deploymentUIDPrefix, openConnections.UserName, peerName, openConnections.CurrentOpenConnections) - - if openConnections.CurrentOpenConnections > int64(lowestOpenConnectionsThreshold) && - a.checkAndAddAlertToCatalog(ctx, alertKey, fmt.Sprintf(alertMessageTemplate, lowestOpenConnectionsThreshold)) { - for _, slackAlertSender := range slackAlertSenders { - if slackAlertSender.openConnectionsAlertThreshold > 0 { - if openConnections.CurrentOpenConnections > int64(slackAlertSender.openConnectionsAlertThreshold) { - a.alertToSlack(ctx, slackAlertSender, alertKey, - fmt.Sprintf(alertMessageTemplate, slackAlertSender.openConnectionsAlertThreshold)) - } - } else { - if openConnections.CurrentOpenConnections > int64(defaultOpenConnectionsThreshold) { - a.alertToSlack(ctx, slackAlertSender, alertKey, - fmt.Sprintf(alertMessageTemplate, defaultOpenConnectionsThreshold)) + ` has exceeded threshold size of %%d connections, currently at %d connections!`, + deploymentUIDPrefix, openConnections.UserName, peerName, openConnections.CurrentOpenConnections) + + if openConnections.CurrentOpenConnections > int64(lowestOpenConnectionsThreshold) { + for _, alertSenderConfig := range alertSenderConfigs { + if a.checkAndAddAlertToCatalog(ctx, + alertSenderConfig.Id, alertKey, fmt.Sprintf(alertMessageTemplate, lowestOpenConnectionsThreshold)) { + if alertSenderConfig.Sender.getOpenConnectionsAlertThreshold() > 0 { + if openConnections.CurrentOpenConnections > int64(alertSenderConfig.Sender.getOpenConnectionsAlertThreshold()) { + a.alertToProvider(ctx, alertSenderConfig, alertKey, + fmt.Sprintf(alertMessageTemplate, alertSenderConfig.Sender.getOpenConnectionsAlertThreshold())) + } + } else { + if openConnections.CurrentOpenConnections > int64(defaultOpenConnectionsThreshold) { + a.alertToProvider(ctx, alertSenderConfig, alertKey, + fmt.Sprintf(alertMessageTemplate, defaultOpenConnectionsThreshold)) + } } } } } } -func (a *Alerter) alertToSlack(ctx context.Context, slackAlertSender *slackAlertSender, alertKey string, alertMessage string) { - err := slackAlertSender.sendAlert(ctx, - ":rotating_light:Alert:rotating_light:: "+alertKey, alertMessage) +func (a *Alerter) alertToProvider(ctx context.Context, alertSenderConfig AlertSenderConfig, alertKey string, alertMessage string) { + err := alertSenderConfig.Sender.sendAlert(ctx, alertKey, alertMessage) if err != nil { logger.LoggerFromCtx(ctx).Warn("failed to send alert", slog.Any("error", err)) return @@ -176,7 +215,7 @@ func (a *Alerter) alertToSlack(ctx context.Context, slackAlertSender *slackAlert // Only raises an alert if another alert with the same key hasn't been raised // in the past X minutes, where X is configurable and defaults to 15 minutes // returns true if alert added to catalog, so proceed with processing alerts to slack -func (a *Alerter) checkAndAddAlertToCatalog(ctx context.Context, alertKey string, alertMessage string) bool { +func (a *Alerter) checkAndAddAlertToCatalog(ctx context.Context, alertConfigId int64, alertKey string, alertMessage string) bool { dur := dynamicconf.PeerDBAlertingGapMinutesAsDuration(ctx) if dur == 0 { logger.LoggerFromCtx(ctx).Warn("Alerting disabled via environment variable, returning") @@ -184,9 +223,9 @@ func (a *Alerter) checkAndAddAlertToCatalog(ctx context.Context, alertKey string } row := a.catalogPool.QueryRow(ctx, - `SELECT created_timestamp FROM peerdb_stats.alerts_v1 WHERE alert_key=$1 + `SELECT created_timestamp FROM peerdb_stats.alerts_v1 WHERE alert_key=$1 AND alert_config_id=$2 ORDER BY created_timestamp DESC LIMIT 1`, - alertKey) + alertKey, alertConfigId) var createdTimestamp time.Time err := row.Scan(&createdTimestamp) if err != nil && err != pgx.ErrNoRows { @@ -196,14 +235,18 @@ func (a *Alerter) checkAndAddAlertToCatalog(ctx context.Context, alertKey string if time.Since(createdTimestamp) >= dur { _, err = a.catalogPool.Exec(ctx, - "INSERT INTO peerdb_stats.alerts_v1(alert_key,alert_message) VALUES($1,$2)", - alertKey, alertMessage) + "INSERT INTO peerdb_stats.alerts_v1(alert_key,alert_message,alert_config_id) VALUES($1,$2,$3)", + alertKey, alertMessage, alertConfigId) if err != nil { logger.LoggerFromCtx(ctx).Warn("failed to insert alert", slog.Any("error", err)) return false } return true } + + logger.LoggerFromCtx(ctx).Info( + fmt.Sprintf("Skipped sending alerts: last alert was sent at %s, which was >=%s ago", + createdTimestamp.String(), dur.String())) return false } diff --git a/flow/alerting/email_alert_sender.go b/flow/alerting/email_alert_sender.go new file mode 100644 index 0000000000..2a534318c0 --- /dev/null +++ b/flow/alerting/email_alert_sender.go @@ -0,0 +1,104 @@ +package alerting + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ses" + "github.com/aws/aws-sdk-go-v2/service/ses/types" + + "github.com/PeerDB-io/peer-flow/logger" + "github.com/PeerDB-io/peer-flow/peerdbenv" + "github.com/PeerDB-io/peer-flow/shared/aws_common" +) + +type EmailAlertSender struct { + AlertSender + client *ses.Client + sourceEmail string + configurationSetName string + replyToAddresses []string + slotLagMBAlertThreshold uint32 + openConnectionsAlertThreshold uint32 + emailAddresses []string +} + +func (e *EmailAlertSender) getSlotLagMBAlertThreshold() uint32 { + return e.slotLagMBAlertThreshold +} + +func (e *EmailAlertSender) getOpenConnectionsAlertThreshold() uint32 { + return e.openConnectionsAlertThreshold +} + +type EmailAlertSenderConfig struct { + sourceEmail string + configurationSetName string + replyToAddresses []string + SlotLagMBAlertThreshold uint32 `json:"slot_lag_mb_alert_threshold"` + OpenConnectionsAlertThreshold uint32 `json:"open_connections_alert_threshold"` + EmailAddresses []string `json:"email_addresses"` +} + +func (e *EmailAlertSender) sendAlert(ctx context.Context, alertTitle string, alertMessage string) error { + _, err := e.client.SendEmail(ctx, &ses.SendEmailInput{ + Destination: &types.Destination{ + ToAddresses: e.emailAddresses, + }, + Message: &types.Message{ + Body: &types.Body{ + Text: &types.Content{ + Data: aws.String(alertMessage), + Charset: aws.String("utf-8"), + }, + }, + Subject: &types.Content{ + Data: aws.String(alertTitle), + Charset: aws.String("utf-8"), + }, + }, + Source: aws.String(e.sourceEmail), + ConfigurationSetName: aws.String(e.configurationSetName), + ReplyToAddresses: e.replyToAddresses, + Tags: []types.MessageTag{ + {Name: aws.String("DeploymentUUID"), Value: aws.String(peerdbenv.PeerDBDeploymentUID())}, + }, + }) + if err != nil { + logger.LoggerFromCtx(ctx).Warn(fmt.Sprintf( + "Error sending email alert from %v to %s subject=[%s], body=[%s], configurationSet=%s, replyToAddresses=[%v]", + e.sourceEmail, e.emailAddresses, alertTitle, alertMessage, e.configurationSetName, e.replyToAddresses)) + return err + } + return nil +} + +func NewEmailAlertSenderWithNewClient(ctx context.Context, region *string, config *EmailAlertSenderConfig) (*EmailAlertSender, error) { + client, err := newSesClient(ctx, region) + if err != nil { + return nil, err + } + return NewEmailAlertSender(client, config), nil +} + +func NewEmailAlertSender(client *ses.Client, config *EmailAlertSenderConfig) *EmailAlertSender { + return &EmailAlertSender{ + client: client, + sourceEmail: config.sourceEmail, + configurationSetName: config.configurationSetName, + replyToAddresses: config.replyToAddresses, + slotLagMBAlertThreshold: config.SlotLagMBAlertThreshold, + openConnectionsAlertThreshold: config.OpenConnectionsAlertThreshold, + emailAddresses: config.EmailAddresses, + } +} + +func newSesClient(ctx context.Context, region *string) (*ses.Client, error) { + sdkConfig, err := aws_common.LoadSdkConfig(ctx, region) + if err != nil { + return nil, err + } + snsClient := ses.NewFromConfig(*sdkConfig) + return snsClient, nil +} diff --git a/flow/alerting/interface.go b/flow/alerting/interface.go new file mode 100644 index 0000000000..a9dd2d51b9 --- /dev/null +++ b/flow/alerting/interface.go @@ -0,0 +1,9 @@ +package alerting + +import "context" + +type AlertSender interface { + sendAlert(ctx context.Context, alertTitle string, alertMessage string) error + getSlotLagMBAlertThreshold() uint32 + getOpenConnectionsAlertThreshold() uint32 +} diff --git a/flow/alerting/slack_alert_sender.go b/flow/alerting/slack_alert_sender.go index 1ad007536b..85f0657d11 100644 --- a/flow/alerting/slack_alert_sender.go +++ b/flow/alerting/slack_alert_sender.go @@ -7,13 +7,22 @@ import ( "github.com/slack-go/slack" ) -type slackAlertSender struct { +type SlackAlertSender struct { + AlertSender client *slack.Client channelIDs []string slotLagMBAlertThreshold uint32 openConnectionsAlertThreshold uint32 } +func (s *SlackAlertSender) getSlotLagMBAlertThreshold() uint32 { + return s.slotLagMBAlertThreshold +} + +func (s *SlackAlertSender) getOpenConnectionsAlertThreshold() uint32 { + return s.openConnectionsAlertThreshold +} + type slackAlertConfig struct { AuthToken string `json:"auth_token"` ChannelIDs []string `json:"channel_ids"` @@ -21,8 +30,8 @@ type slackAlertConfig struct { OpenConnectionsAlertThreshold uint32 `json:"open_connections_alert_threshold"` } -func newSlackAlertSender(config *slackAlertConfig) *slackAlertSender { - return &slackAlertSender{ +func newSlackAlertSender(config *slackAlertConfig) *SlackAlertSender { + return &SlackAlertSender{ client: slack.New(config.AuthToken), channelIDs: config.ChannelIDs, slotLagMBAlertThreshold: config.SlotLagMBAlertThreshold, @@ -30,11 +39,11 @@ func newSlackAlertSender(config *slackAlertConfig) *slackAlertSender { } } -func (s *slackAlertSender) sendAlert(ctx context.Context, alertTitle string, alertMessage string) error { +func (s *SlackAlertSender) sendAlert(ctx context.Context, alertTitle string, alertMessage string) error { for _, channelID := range s.channelIDs { _, _, _, err := s.client.SendMessageContext(ctx, channelID, slack.MsgOptionBlocks( - slack.NewHeaderBlock(slack.NewTextBlockObject("plain_text", alertTitle, true, false)), - slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", alertMessage, false, false), nil, nil), + slack.NewHeaderBlock(slack.NewTextBlockObject("plain_text", ":rotating_light:Alert:rotating_light:: "+alertTitle, true, false)), + slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", alertMessage+"\ncc: ", false, false), nil, nil), )) if err != nil { return fmt.Errorf("failed to send message to Slack channel %s: %w", channelID, err) diff --git a/flow/alerting/types.go b/flow/alerting/types.go new file mode 100644 index 0000000000..6277010ee0 --- /dev/null +++ b/flow/alerting/types.go @@ -0,0 +1,8 @@ +package alerting + +type ServiceType string + +const ( + SLACK ServiceType = "slack" + EMAIL ServiceType = "email" +) diff --git a/flow/go.mod b/flow/go.mod index 6c319239ae..5126e3146d 100644 --- a/flow/go.mod +++ b/flow/go.mod @@ -15,6 +15,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.7 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.9 github.com/aws/aws-sdk-go-v2/service/s3 v1.51.4 + github.com/aws/aws-sdk-go-v2/service/ses v1.22.2 github.com/aws/aws-sdk-go-v2/service/sns v1.29.2 github.com/cockroachdb/pebble v1.1.0 github.com/google/uuid v1.6.0 diff --git a/flow/go.sum b/flow/go.sum index acb83d06ba..377b2c2a16 100644 --- a/flow/go.sum +++ b/flow/go.sum @@ -92,6 +92,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.3 h1:4t+QEX7BsXz98W github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.3/go.mod h1:oFcjjUq5Hm09N9rpxTdeMeLeQcxS7mIkBkL8qUKng+A= github.com/aws/aws-sdk-go-v2/service/s3 v1.51.4 h1:lW5xUzOPGAMY7HPuNF4FdyBwRc3UJ/e8KsapbesVeNU= github.com/aws/aws-sdk-go-v2/service/s3 v1.51.4/go.mod h1:MGTaf3x/+z7ZGugCGvepnx2DS6+caCYYqKhzVoLNYPk= +github.com/aws/aws-sdk-go-v2/service/ses v1.22.2 h1:cW5JtW23Lio3KDJ4l3jqRiOcCPKxJg7ooRA1SpIiuMo= +github.com/aws/aws-sdk-go-v2/service/ses v1.22.2/go.mod h1:MLj/NROJoperecxBME2zMN/O8Zrm0wv+6ah1Uqwpa1E= github.com/aws/aws-sdk-go-v2/service/sns v1.29.2 h1:kHm1SYs/NkxZpKINc4zOXOLJHVMzKtU4d7FlAMtDm50= github.com/aws/aws-sdk-go-v2/service/sns v1.29.2/go.mod h1:ZIs7/BaYel9NODoYa8PW39o15SFAXDEb4DxOG2It15U= github.com/aws/aws-sdk-go-v2/service/sso v1.20.2 h1:XOPfar83RIRPEzfihnp+U6udOveKZJvPQ76SKWrLRHc= diff --git a/flow/peerdbenv/config.go b/flow/peerdbenv/config.go index 9a59bad5ef..4903bec091 100644 --- a/flow/peerdbenv/config.go +++ b/flow/peerdbenv/config.go @@ -95,3 +95,20 @@ func PeerDBEnableParallelSyncNormalize() bool { func PeerDBTelemetryAWSSNSTopicArn() string { return getEnvString("PEERDB_TELEMETRY_AWS_SNS_TOPIC_ARN", "") } + +func PeerDBAlertingEmailSenderSourceEmail() string { + return getEnvString("PEERDB_ALERTING_EMAIL_SENDER_SOURCE_EMAIL", "") +} + +func PeerDBAlertingEmailSenderConfigurationSet() string { + return getEnvString("PEERDB_ALERTING_EMAIL_SENDER_CONFIGURATION_SET", "") +} + +func PeerDBAlertingEmailSenderRegion() string { + return getEnvString("PEERDB_ALERTING_EMAIL_SENDER_REGION", "") +} + +// Comma-separated reply-to addresses +func PeerDBAlertingEmailSenderReplyToAddresses() string { + return getEnvString("PEERDB_ALERTING_EMAIL_SENDER_REPLY_TO_ADDRESSES", "") +} diff --git a/flow/shared/aws_common/config.go b/flow/shared/aws_common/config.go new file mode 100644 index 0000000000..6eced96f05 --- /dev/null +++ b/flow/shared/aws_common/config.go @@ -0,0 +1,21 @@ +package aws_common + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" +) + +func LoadSdkConfig(ctx context.Context, region *string) (*aws.Config, error) { + sdkConfig, err := config.LoadDefaultConfig(ctx, func(options *config.LoadOptions) error { + if region != nil { + options.Region = *region + } + return nil + }) + if err != nil { + return nil, err + } + return &sdkConfig, nil +} diff --git a/flow/shared/telemetry/sns_message_sender.go b/flow/shared/telemetry/sns_message_sender.go index 9d32dcf8f9..218b693b38 100644 --- a/flow/shared/telemetry/sns_message_sender.go +++ b/flow/shared/telemetry/sns_message_sender.go @@ -8,10 +8,11 @@ import ( "unicode" "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sns/types" "go.temporal.io/sdk/activity" + + "github.com/PeerDB-io/peer-flow/shared/aws_common" ) type SNSMessageSender interface { @@ -109,15 +110,10 @@ func NewSNSMessageSender(client *sns.Client, config *SNSMessageSenderConfig) SNS } func newSnsClient(ctx context.Context, region *string) (*sns.Client, error) { - sdkConfig, err := config.LoadDefaultConfig(ctx, func(options *config.LoadOptions) error { - if region != nil { - options.Region = *region - } - return nil - }) + sdkConfig, err := aws_common.LoadSdkConfig(ctx, region) if err != nil { return nil, err } - snsClient := sns.NewFromConfig(sdkConfig) + snsClient := sns.NewFromConfig(*sdkConfig) return snsClient, nil } diff --git a/nexus/catalog/migrations/V21__alert_constraint_update.sql b/nexus/catalog/migrations/V21__alert_constraint_update.sql new file mode 100644 index 0000000000..18ddb7d9f8 --- /dev/null +++ b/nexus/catalog/migrations/V21__alert_constraint_update.sql @@ -0,0 +1,6 @@ +ALTER TABLE peerdb_stats.alerting_config +DROP CONSTRAINT alerting_config_service_type_check; + +ALTER TABLE peerdb_stats.alerting_config +ADD CONSTRAINT alerting_config_service_type_check +CHECK (service_type IN ('slack', 'email')); \ No newline at end of file diff --git a/nexus/catalog/migrations/V22__alert_column_add_config_id.sql b/nexus/catalog/migrations/V22__alert_column_add_config_id.sql new file mode 100644 index 0000000000..35c1147bf0 --- /dev/null +++ b/nexus/catalog/migrations/V22__alert_column_add_config_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE peerdb_stats.alerts_v1 +ADD COLUMN alert_config_id BIGINT DEFAULT NULL; diff --git a/ui/app/alert-config/new.tsx b/ui/app/alert-config/new.tsx index 6a399e4c81..12dfbc9cf5 100644 --- a/ui/app/alert-config/new.tsx +++ b/ui/app/alert-config/new.tsx @@ -1,21 +1,27 @@ import { Button } from '@/lib/Button'; import { TextField } from '@/lib/TextField'; import Image from 'next/image'; -import { useState } from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; import ReactSelect from 'react-select'; import { PulseLoader } from 'react-spinners'; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import SelectTheme from '../styles/select'; -import { alertConfigReqSchema, alertConfigType } from './validation'; +import { + alertConfigReqSchema, + alertConfigType, + emailConfigType, + serviceConfigType, + serviceTypeSchemaMap, + slackConfigType, +} from './validation'; + +export type ServiceType = 'slack' | 'email'; export interface AlertConfigProps { id?: bigint; - serviceType: string; - authToken: string; - channelIdString: string; - slotLagGBAlertThreshold: number; - openConnectionsAlertThreshold: number; + serviceType: ServiceType; + alertConfig: serviceConfigType; forEdit?: boolean; } @@ -25,45 +31,152 @@ const notifyErr = (errMsg: string) => { }); }; -function ConfigLabel() { +function ConfigLabel(data: { label: string; value: string }) { return (
- slack + {data.value} + {data.label}
); } -const NewAlertConfig = (alertProps: AlertConfigProps) => { - const [serviceType, setServiceType] = useState('slack'); - const [authToken, setAuthToken] = useState(alertProps.authToken); - const [channelIdString, setChannelIdString] = useState( - alertProps.channelIdString +function getSlackProps( + config: slackConfigType, + setConfig: Dispatch> +) { + return ( + <> +
+

Authorisation Token

+ { + setConfig((previous) => ({ + ...previous, + auth_token: e.target.value, + })); + }} + /> +
+
+

Channel IDs

+ { + setConfig((previous) => ({ + ...previous, + channel_ids: e.target.value.split(','), + })); + }} + /> +
+ + ); +} + +function getEmailProps( + config: emailConfigType, + setConfig: Dispatch> +) { + return ( + <> +
+

Email Addresses

+ { + setConfig((previous) => ({ + ...previous, + email_addresses: e.target.value.split(','), + })); + }} + /> +
+ + ); +} +function getServiceFields( + serviceType: ServiceType, + config: T, + setConfig: Dispatch> +) { + switch (serviceType) { + case 'email': + return getEmailProps( + config as emailConfigType, + setConfig as Dispatch> + ); + case 'slack': { + return getSlackProps( + config as slackConfigType, + setConfig as Dispatch> + ); + } + } +} + +export function NewConfig(alertProps: AlertConfigProps) { + const [serviceType, setServiceType] = useState( + alertProps.serviceType ); - const [slotLagGBAlertThreshold, setSlotLagGBAlertThreshold] = - useState(alertProps.slotLagGBAlertThreshold); - const [openConnectionsAlertThreshold, setOpenConnectionsAlertThreshold] = - useState(alertProps.openConnectionsAlertThreshold); + + const [config, setConfig] = useState( + alertProps.alertConfig + ); + const [loading, setLoading] = useState(false); + const handleAdd = async () => { - if (serviceType !== 'slack') { - notifyErr('Service Type must be selected'); + if (!serviceType) { + notifyErr('Service type must be selected'); + return; + } + + const serviceSchema = serviceTypeSchemaMap[serviceType]; + const serviceValidity = serviceSchema.safeParse(config); + if (!serviceValidity?.success) { + notifyErr( + 'Invalid alert service configuration for ' + + serviceType + + '. ' + + serviceValidity.error.issues[0].message + ); return; } + const serviceConfig = serviceValidity.data; const alertConfigReq: alertConfigType = { + id: Number(alertProps.id || -1), serviceType: serviceType, - serviceConfig: { - auth_token: authToken ?? '', - channel_ids: channelIdString?.split(',')!, - slot_lag_mb_alert_threshold: slotLagGBAlertThreshold * 1000 || 20000, - open_connections_alert_threshold: openConnectionsAlertThreshold || 5, - }, + serviceConfig, }; + const alertReqValidity = alertConfigReqSchema.safeParse(alertConfigReq); if (!alertReqValidity.success) { notifyErr(alertReqValidity.error.issues[0].message); return; } + setLoading(true); if (alertProps.forEdit) { alertConfigReq.id = Number(alertProps.id); @@ -72,6 +185,7 @@ const NewAlertConfig = (alertProps: AlertConfigProps) => { method: alertProps.forEdit ? 'PUT' : 'POST', body: JSON.stringify(alertConfigReq), }); + const createStatus = await createRes.text(); setLoading(false); if (createStatus !== 'success') { @@ -81,7 +195,7 @@ const NewAlertConfig = (alertProps: AlertConfigProps) => { window.location.reload(); }; - + const ServiceFields = getServiceFields(serviceType, config, setConfig); return (
{

Alert Provider

val && setServiceType(val.value)} + onChange={(val, _) => val && setServiceType(val.value as ServiceType)} theme={SelectTheme} />
-
-

Authorisation Token

- setAuthToken(e.target.value)} - /> -
- -
-

Channel IDs

- setChannelIdString(e.target.value)} - /> -
-

Slot Lag Alert Threshold (in GB)

setSlotLagGBAlertThreshold(e.target.valueAsNumber)} + value={config.slot_lag_mb_alert_threshold / 1000} + onChange={(e) => + setConfig((previous) => ({ + ...previous, + slot_lag_mb_alert_threshold: e.target.valueAsNumber * 1000, + })) + } />
-

Open Connections Alert Threshold

- setOpenConnectionsAlertThreshold(e.target.valueAsNumber) + setConfig((previous) => ({ + ...previous, + open_connections_alert_threshold: e.target.valueAsNumber, + })) } />
- + {ServiceFields}
); -}; +} -export default NewAlertConfig; +export default NewConfig; diff --git a/ui/app/alert-config/page.tsx b/ui/app/alert-config/page.tsx index 514f586155..25a92338b9 100644 --- a/ui/app/alert-config/page.tsx +++ b/ui/app/alert-config/page.tsx @@ -12,13 +12,25 @@ import useSWR from 'swr'; import { UAlertConfigResponse } from '../dto/AlertDTO'; import { tableStyle } from '../peers/[peerName]/style'; import { fetcher } from '../utils/swr'; -import NewAlertConfig, { AlertConfigProps } from './new'; -const ServiceIcon = (serviceType: string) => { - switch (serviceType.toLowerCase()) { - default: - return alt; - } +import { AlertConfigProps, NewConfig, ServiceType } from './new'; + +const ServiceIcon = ({ + serviceType, + size, +}: { + serviceType: string; + size: number; +}) => { + return ( + {serviceType} + ); }; + const AlertConfigPage: React.FC = () => { const { data: alerts, @@ -28,14 +40,19 @@ const AlertConfigPage: React.FC = () => { error: any; isLoading: boolean; } = useSWR('/api/alert-config', fetcher); + const blankAlert: AlertConfigProps = { - serviceType: '', - authToken: '', - channelIdString: '', - slotLagGBAlertThreshold: 20, - openConnectionsAlertThreshold: 5, + serviceType: 'slack', + alertConfig: { + email_addresses: [''], + auth_token: '', + channel_ids: [''], + open_connections_alert_threshold: 20, + slot_lag_mb_alert_threshold: 5000, + }, forEdit: false, }; + const [inEditOrAddMode, setInEditOrAddMode] = useState(false); const [editAlertConfig, setEditAlertConfig] = useState(blankAlert); @@ -43,19 +60,15 @@ const AlertConfigPage: React.FC = () => { const onEdit = (alertConfig: UAlertConfigResponse, id: bigint) => { setInEditOrAddMode(true); const configJSON = JSON.stringify(alertConfig.service_config); - const channelIds: string[] = JSON.parse(configJSON)?.channel_ids; + setEditAlertConfig({ id, - serviceType: alertConfig.service_type, - authToken: JSON.parse(configJSON)?.auth_token, - channelIdString: channelIds.join(','), - slotLagGBAlertThreshold: - (JSON.parse(configJSON)?.slot_lag_mb_alert_threshold as number) / 1000, - openConnectionsAlertThreshold: - JSON.parse(configJSON)?.open_connections_alert_threshold, + serviceType: alertConfig.service_type as ServiceType, + alertConfig: JSON.parse(configJSON), forEdit: true, }); }; + return (
@@ -79,11 +92,32 @@ const AlertConfigPage: React.FC = () => { {alerts?.length ? ( alerts.map((alertConfig: UAlertConfigResponse, index) => ( - - {alertConfig.id} - - - {ServiceIcon(alertConfig.service_type)} + +
+
+ + +
+
@@ -141,7 +175,7 @@ const AlertConfigPage: React.FC = () => { {inEditOrAddMode ? 'Cancel' : 'Add Configuration'} - {inEditOrAddMode && } + {inEditOrAddMode && }
); }; diff --git a/ui/app/alert-config/validation.ts b/ui/app/alert-config/validation.ts index 3256c51aa0..9b1a14b2b3 100644 --- a/ui/app/alert-config/validation.ts +++ b/ui/app/alert-config/validation.ts @@ -1,33 +1,77 @@ import z from 'zod'; -export const alertConfigReqSchema = z.object({ - id: z.optional(z.number()), - serviceType: z.enum(['slack'], { - errorMap: (issue, ctx) => ({ message: 'Invalid service type' }), - }), - serviceConfig: z.object({ +const baseServiceConfigSchema = z.object({ + slot_lag_mb_alert_threshold: z + .number({ + invalid_type_error: 'Slot threshold must be a number', + }) + .min(0, 'Slot threshold must be non-negative'), + open_connections_alert_threshold: z + .number({ + invalid_type_error: 'Threshold must be a number', + }) + .int({ message: 'Connections threshold must be a valid integer' }) + .min(0, 'Connections threshold must be non-negative'), +}); + +export const slackServiceConfigSchema = z.intersection( + baseServiceConfigSchema, + z.object({ auth_token: z .string({ required_error: 'Auth Token is needed.' }) .min(1, { message: 'Auth Token cannot be empty' }) .max(256, { message: 'Auth Token is too long' }), channel_ids: z - .array(z.string().min(1, { message: 'Channel IDs cannot be empty' }), { - required_error: 'We need a channel ID', - }) + .array( + z.string().trim().min(1, { message: 'Channel IDs cannot be empty' }), + { + required_error: 'We need a channel ID', + } + ) .min(1, { message: 'Atleast one channel ID is needed' }), - slot_lag_mb_alert_threshold: z - .number({ - invalid_type_error: 'Threshold must be a number', - }) - .int() - .min(0, 'Threshold must be non-negative'), - open_connections_alert_threshold: z - .number({ - invalid_type_error: 'Threshold must be a number', - }) - .int() - .min(0, 'Threshold must be non-negative'), + }) +); + +export const emailServiceConfigSchema = z.intersection( + baseServiceConfigSchema, + z.object({ + email_addresses: z + .array( + z + .string() + .trim() + .min(1, { message: 'Email Addresses cannot be empty' }) + .includes('@'), + { + required_error: 'We need an Email Address', + } + ) + .min(1, { message: 'Atleast one email address is needed' }), + }) +); + +export const serviceConfigSchema = z.union([ + slackServiceConfigSchema, + emailServiceConfigSchema, +]); +export const alertConfigReqSchema = z.object({ + id: z.optional(z.number({ invalid_type_error: 'ID must be a valid number' })), + serviceType: z.enum(['slack', 'email'], { + errorMap: (issue, ctx) => ({ message: 'Invalid service type' }), }), + serviceConfig: serviceConfigSchema, }); +export type baseServiceConfigType = z.infer; + +export type slackConfigType = z.infer; +export type emailConfigType = z.infer; + +export type serviceConfigType = z.infer; + export type alertConfigType = z.infer; + +export const serviceTypeSchemaMap = { + slack: slackServiceConfigSchema, + email: emailServiceConfigSchema, +}; diff --git a/ui/app/api/alert-config/route.ts b/ui/app/api/alert-config/route.ts index 7f66da191f..9b8ff019ed 100644 --- a/ui/app/api/alert-config/route.ts +++ b/ui/app/api/alert-config/route.ts @@ -23,6 +23,7 @@ export async function POST(request: Request) { if (createRes.id) { createStatus = 'success'; } + return new Response(createStatus); } diff --git a/ui/components/AlertDropdown.tsx b/ui/components/AlertDropdown.tsx index dd3ae482e3..bfc30648a7 100644 --- a/ui/components/AlertDropdown.tsx +++ b/ui/components/AlertDropdown.tsx @@ -40,19 +40,16 @@ const AlertDropdown = ({ borderRadius: '0.5rem', }} > - - - - - - + + + diff --git a/ui/public/images/email.png b/ui/public/images/email.png new file mode 100644 index 0000000000000000000000000000000000000000..0d302af32a0e57210a55831232e060bf016bd8a1 GIT binary patch literal 18231 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 B-!=dM literal 0 HcmV?d00001 diff --git a/ui/public/images/slack.png b/ui/public/images/slack.png index 32fe41912abaf6f81b1fef4d155fbf5609208325..a045ed1962aa6ffaf6f131e067ac8d1f077fe39a 100644 GIT binary patch literal 6999 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 Z4f%DeE5@xUiQw}w1Z!qvT4C%N^FOXGSH}PV literal 6109 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 diff --git a/ui/public/images/slack_full.png b/ui/public/images/slack_full.png new file mode 100644 index 0000000000000000000000000000000000000000..32fe41912abaf6f81b1fef4d155fbf5609208325 GIT binary patch literal 6109 zcmXYVbyO7I^Y(%)2uLcOiu584QlfyQbS=GfcXxMp3Mdl70*iDnu#|L5!_u{MDe?0C zecnIj#+*6ldFHwIoO@@YRg`4#a42yA005qxtdtr6fChS6e|e7iG|oN5_# z008dG{}~z}CyyKeAWM{!`lR8Nck~t8@0DhL|LW*E@@Q8dwYeKLjK#l)a@*Pc2q3VS zKFy;@#Arq*PBf@C5NE?lz#yRxDP!QRNZ_TVvvl(?4=1P33afbeTrLRm29|=Woe-8q zC@Y&2)_A-+L(@`m;8Sg=DQJKHpxzj)tspn=vn0TL|V$c`$VqZI|OBuNKoEc&Tg=Hkh$VNr#dV?zy32fte4xc zJzu)~2)=kGMK+qksE`r$Jj%rrDXM%=&lJ?Hvr^c4|2X>g%5J|)Xc2R@wMFxpxole; z9`3Po8rgc?%(=7fo}V4vsi>WiqP|+#@>c9Bh2D9~>Cr!rJzvCP^c| zDLlhOuYk}|BKcYw^?;(MB}xB+M*_DwfUAu$p(#zGNWai;&j*|-D5s*=$%%epVM30?M|NEa5|5M+(3S7T7KXpnIKnc5%{-v6{A zVTzmJAGu*ue);Zn8b3*}(lIKL`2FCY#qj>XXmtrmFg%9hKNcxd?pR!B)bJvgoYPyx zPOuna4)fu~T_atq)Md>RT-<+(_&=N&21s(%{8;0~cO!r9AcxIe zDvyGX*Y=YSpj=GQ^^2X2t>K~h>;KR_2uXB#BqGwTXdX3}lZB;yOfoG*}!=TRDG`8iS zUqOZ6r(ECj@8T^U98@Eh$TK1kerM*5KavS)`07@LhQWdH9Ya9$%^X5}+q> z+LU*CcTD^UezUC<0iMrWct#nD0r+ZSH;0C7My$?cM`puqjNfaQRg2QZz}b8c;ENQw zA+FzF1f|8@rz#xy8*E&!x&F|##()8|Mt=ht8In?jNCjGC8MqkM7IIrmSvUMAD@&6) zs5o};k?1j^!Ae8RqQGa<$QBHBqO}hu#v>V9L{a&aIepUfE8mzEJr?JlQ`qGD;SJ0}$d7 zPs)yEw<%%$y%uB{;^ZrVXKFyEwrRl!3n;1;i+%IPLeq2A+(S@hbc6EL&^e$jcx$C& zv$;;~)}b)v>k658QIig|g)|$tT!oo!vzItgnyYNVT7t^>&XT>y83fO9l<-B+N-XrV z#AD2A3NO~E@z`WE?)Xb$&d#@ldrhvK?Bt2S_6;4rF9%y9ws4(L6a19NWT|V7o$~}< z2c6=)%sN>N|@6ISDlmFq*4`geKc z%GtH3t+ZWi_ITgB4w)1?q#F7se7_D_txTa(_)=+dYh&499aPVPc?PWTXW_yDj|jV< zq~(poYum+EhRMBqLIm5LM~Jq}g~7(aTotoJ7Sd-)6anMu!)1CF(}?EP@4I4uO8GYX ziM3-5V@+DEp(NlbQ~J5_e?HL?Rnh?4$^^g2^Vo~Q@}WncYD9xG%?^3}cW}^n3z4C%0PxbsC z6`z5I&&Ve?!E1pC*SOE!hc?boG0H?s@8{`m?51CY=$KJRYTjuZc+SYFK66x})2NY+ zeiRvON!Zm_>EV~Wl|Dm2gd1MGPDGLQVL6qrjlcaIO^NkAeFy{UeG=}J8*KFaG43Y#qvFW~z1x!Pft_atma5!iKRK@zc|kCCi7BH=kKenj zXBt1xsrle%dvT9sNKkYWAV(ysf?}iNdlD}Jq~-!&;yzg-V6x>Wun7A1B|A@#JZY|w z$CgO)Kaz)U`T={nnGC+YAuf-1tTU|G=T1JKC!&W96y9P{SqE>pA?bV@s1Y?g>)TJe%(=jru8kM>G<{0H6VliXolPqNX$u| z@QMJkr?cAf*|e1bI8Hei7Atni{Mk`F@96l!J@I{o1H{wgWe1p}bC8Dajpa;ib^Ndz zf&T`Z*s{H=2Ix+-eVb%{z9R*WqjN$nVAXj=k!Pr*@pY={5VVb3p-4rz93L>U=Jx~f z5`4_v7us0%PDB0&AEyWOGZ}R@bu4G+&h6ZZxX41L! zlxp6cS@>S%=LfvZFUNw$Fyp_1!5O8aJ5;M-?96-{RK}4sl;#4UcE0w%#f>xvWFGSh z1(%^isydqr)rJ~-RR%sA9Bf9XcW|8UpHdj*YTyuFIwW3_!){C5R|Tf;Ch9h(HBjCP zMkQW`<{&Th*!q8N*&19pie{qK#IS%qa>rkz4R@Na2v*zzK2d}M!Jpc@ob1M(9yN{B z$}s2$?cr7?BI3&Q9SzxSgK{935=~V9V{p}96K)qr-3Wv_Sz6liNnhDTU$5-w{AKL8 znPW2Qj-}Z09dR5`OyQ0bb7ue-j2%}q&D3BsvmCdR`D|7+0?g@Rqu}Ix=O5Q!i+*4j zu^};1bQWIBf`fCo=M4yn*dl!VD51S!H!5_7u7xH^eN}^R&Ck6_j|Ezo!S-vB2uWks zDUSt$Qz;U#FDr#z8(uLW(LgoEhKmwEI@x=33evzW#A-%k7g1ZWxzsSLjp6MjQZW;> zjQr|v%9OLjaVrx2)p?;xhWU|xJhYUl$}4h##jJ+FoNzx=%Oo$)B6aQkW0&=EEW)ab zLNrHG%FV-drb^Qc{YzFJOx;gCJ=n`+gA&n{2wZSo@3|7scE#Ha^Z+$@OVVp4(BLp| z$B|+ZU9{w6X(ersljfJ=@@igMq~6OjXNB%pa*d2iU4OK8HR1%X)?L@lf3Lc@Yp1tk z>Ggdmx~U9e|IG@aC;g(@<#WLoac2AQkll{2E##GmHMnPNMq3A0R^RNy1*_#+u-tmS z60#xTgc!(BY;@vT#nNV@K`l@^lGBoMysLH$5VWK^o1k<+z8_$gB>5DF5N#qxfT--P z;;2mFY+~wh=KXN=qT^?O^DB~oEa;0rDiC1XBnnI0D7y%E&KrDSF||)(_O8x`f4dhK zj5IKwwnJnQa6;}wc%=EZ$5;nnK)U*v%Q@d_>?^W@tQ7Lsv64ttexg-~6t4|7Y_jHT zbUjx02y6`93+$oRf8y<(-#Lm|1j}!Zrd!{Dvum*ha|9ZC&lnXG7n5AP%@E|;^8Ku) zqTf=;V!hB}8`3WSVb*<49Ns7Pa2je7-=mdUaf(2r6b%ZuCkUi#3GS*YC;@LOLciYa zDP;4j=h0Pp#tW-%pY*Wl!ifvb`#ScqzYtH;ZkAtG>@sTMpYg#R zX+j6{Cb#{+xH^#EV0R~4*V_yc1ix(0ozF@lJoH}MNqe>u?ZdOq-0DH(nClNO|&d2=H`?-MxM z3>}(a70L$H1E*JddT?`g`LSj3-^skyNZ$l4~M7dj!UcTrL&$*Yx3GUUM6@&_$Mey_aXpzGr)VD8SZrnhKV%M|5a1#hkfp&byss0rx#$)S?*yyZ_O zFYOpQ^cW+-KV$umitYRivzk4&cOV3)rQkw~swIg?cbNb}5q_)?`?#wc&GGwjM(8D% z!4AvwDuy_U@ei`;Jf}~Wf`68%e0C_d-lKyW#zV2Oci+qWkn$om*ji9_LeH=yZv94O zk>#8bE}!qx9{51=41)is#KUwV&i7f+r3y68_^a-*U10}&Vh%S7^*ZLyumF8N{BIUF zty||qcKKx~&{50Briy*0N4S@epbxixdNOlTBfN=I1+lIQuTFn0J+6Y2?XT`#1fYhz*YG1w$To=lKGDx%cR*?LXl}A-09unpSI56q+z*?4{@lkP< zU;oMx+^nt(M~4Zgi5{{0?R9=eAn`ziE1Ox zWnNx$5oZt#M$a+55)xiK)*Rqgb3)9q0u}U9N+I=Id=EN%(Nma_@7UWk*fNOVe@zBC z$^s1*BEJ@7&GEmQCC1q~V0R4&ZC9FQ!_wW7NERf>S&2hm z(*!KTANvxOXPPFSJeYYcT~4<<>{=yZXT6T-R+W(%%l7=EH7?Z>EqN5}ed9DaU1ciA ztMymSLK)G>o>|Y017^5A`rWjaCU_qHFgAJZmO)sS_Jdd?Y6{)s=F{}0H9#Ku$Z>d# z+)%qW)%3TW5-$mID4Fks3)&}r*J=+GLzm$OG`$4_^JMWtVo#GoL`VKN`Y@bo7K5e~J17%DG< zyPre4#W zx0Yn8+fQXIL_TSS`}Z8{*iQpYKt-g-Br|BMOOJO{$|{)Y^+vxqcaLHNqi{>~JQ96RI(GT}=JCVgoYML54+c>A7sZ(;|jFH{uOm~FO&g%HP zOSD=C7H6uSX2f=9h9GGW$%Rw%fhr0wE+5iPdUw*@#t8mZbjj3%F||YJJMyCt5}72z zPf2>gmabI^E&rglty{ji+iJ=k%0H+p~&e^|^1nHwWLex;D{h|XKYtpZ=zh;NE6k`4pxNfw6Q~uO5t=l*Iop%q))xCM0nS_ty7AG=3+m;us>DT$CC)0ydmalQsrFgR28nbP9MM%pgff^n~JO9lDZ zX1r?61}7VJneD^X!wr7yt^V?{2f8D@n)>)t-TN)uKKLu}RRuo*juNghDNzm!JMs)vR$LfMRqw)GI8{VYV8@#22+Y16BFjQm#XTe+s0{z`>|3>D|>jR zL?ea_Jlz5gr`rs~02P5>+cA9Ov#D*L!r!L^#?;tfiK6M=f~~)>!@l(z=G_(gi4w!A z5tn{DIFZ7z=g@>|cT}H<`iL#;e_ZU)>U;)rXV(#Xp`Rewrh27#A4RhCPfDgyB1EUM z@O7N)>VLDsO5NLp4DNHS>T)^#rHn5@bN)fbtj-@inMMxOjVti`hq gdtzcD@ksF5cbVOS_U*<~zaAj>SxKr&0uu8700L0;!vFvP literal 0 HcmV?d00001