Skip to content

Commit

Permalink
[R] Route SNS notifications through a Lambda function (#5246)
Browse files Browse the repository at this point in the history
  • Loading branch information
achave11-ucsc committed Aug 28, 2024
1 parent 751e60c commit a2a362b
Show file tree
Hide file tree
Showing 11 changed files with 187 additions and 3 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
_link dev
_refresh
export azul_aws_account_name=test-hca-dev
make virtualenv
source .venv/bin/activate
make requirements
Expand Down
4 changes: 4 additions & 0 deletions environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ def env() -> Mapping[str, Optional[str]]:
#
'AZUL_AWS_ACCOUNT_ID': None,

# Description
#
# Not set explicitly, do not modify for test and unittest usage only
'azul_aws_account_name': None,
# The region of the Azul deployment. This variable is primarily used by
# the AWS CLI, by TerraForm, botocore and boto3 but Azul references it
# too. This variable is typically set in deployment-specific
Expand Down
19 changes: 19 additions & 0 deletions lambdas/indexer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
from azul.indexer.log_forwarding_controller import (
LogForwardingController,
)
from azul.indexer.notification_controller import (
MonitoringController,
)
from azul.logging import (
configure_app_logging,
)
Expand Down Expand Up @@ -75,6 +78,10 @@ class IndexerApp(AzulChaliceApp, SignatureHelper):
def health_controller(self):
return self._controller(HealthController, lambda_name='indexer')

@cached_property
def monitoring_controller(self):
return self._controller(MonitoringController)

@cached_property
def index_controller(self) -> IndexController:
return self._controller(IndexController)
Expand Down Expand Up @@ -108,6 +115,13 @@ def decorator(f):
else:
return lambda func: func

@property
def monitoring_sns_handler(self):
if config.deployment.is_main:
return self.on_sns_message(topic=aws.monitoring_topic_name)
else:
return lambda func: func

def _authenticate(self) -> Optional[HMACAuthentication]:
return self.auth_from_request(self.current_request)

Expand Down Expand Up @@ -367,3 +381,8 @@ def forward_alb_logs(event: chalice.app.S3Event):
)
def forward_s3_logs(event: chalice.app.S3Event):
app.log_controller.forward_s3_access_logs(event)


@app.monitoring_sns_handler
def notify(event: chalice.app.SNSEvent):
app.monitoring_controller.notify_group(event)
1 change: 1 addition & 0 deletions requirements.all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ mypy-boto3-iam==1.28.79
mypy-boto3-kms==1.28.37
mypy-boto3-lambda==1.28.83
mypy-boto3-s3==1.28.55
mypy-boto3-sesv2==1.28.64
mypy-boto3-sqs==1.28.82
mypy-boto3-stepfunctions==1.28.36
openapi-schema-validator==0.3.4
Expand Down
1 change: 1 addition & 0 deletions requirements.dev.trans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mypy-boto3-iam==1.28.79
mypy-boto3-kms==1.28.37
mypy-boto3-lambda==1.28.83
mypy-boto3-s3==1.28.55
mypy-boto3-sesv2==1.28.64
mypy-boto3-sqs==1.28.82
mypy-boto3-stepfunctions==1.28.36
openapi-schema-validator==0.3.4
Expand Down
2 changes: 1 addition & 1 deletion requirements.dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ google-cloud-bigquery-reservation==1.11.3
jq==1.3.0
locust==2.12.2
moto[s3,sqs,sns,dynamodb,iam]==4.1.13 # match the extras with the backends listed in AzulUnitTestCase._reset_moto
boto3-stubs[s3,sqs,lambda,dynamodb,iam,ecr,stepfunctions,kms]==1.28.63 # match this with the version of the `boto3` runtime dependency
boto3-stubs[s3,sqs,lambda,dynamodb,iam,ecr,stepfunctions,kms,sesv2]==1.28.63 # match this with the version of the `boto3` runtime dependency
openapi-spec-validator==0.5.1
openpyxl==3.0.6
posix_ipc==1.1.1
Expand Down
19 changes: 18 additions & 1 deletion src/azul/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
AWSResponse,
)
import botocore.credentials
from botocore.exceptions import (
NoCredentialsError,
)
import botocore.session
import botocore.utils
from more_itertools import (
Expand Down Expand Up @@ -71,6 +74,9 @@
from mypy_boto3_s3 import (
S3Client,
)
from mypy_boto3_sesv2 import (
SESV2Client,
)
from mypy_boto3_stepfunctions import (
SFNClient,
)
Expand Down Expand Up @@ -158,6 +164,10 @@ def securityhub(self):
def sns(self):
return self.client('sns')

@property
def ses(self) -> 'SESV2Client':
return self.client('sesv2')

@property
def sts(self):
return self.client('sts')
Expand Down Expand Up @@ -667,8 +677,15 @@ def _validate_bucket_path_prefix(self, path_prefix):

@property
def monitoring_topic_name(self):
try:
stage = config.main_deployment_stage
except NoCredentialsError:
# Running `make openapi` in GitHub fails since retrieving the main
# deployment stage requires making a roundtrip to IAM. The
# monitoring topic is used by the monitoring Lambda.
stage = config.deployment_stage
return config.qualified_resource_name('monitoring',
stage=config.main_deployment_stage)
stage=stage)


aws = AWS()
Expand Down
40 changes: 40 additions & 0 deletions src/azul/indexer/lambda_iam_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,46 @@
f"arn:aws:secretsmanager:{aws.region_name}:{aws.account}:secret:*"
]
},
{
"Effect": "Allow",
"Action": [
'ses:SendEmail',
'ses:SendRawEmail'
],
"Resource": [
# TODO; compose the arn of resource
f"arn:aws:ses:{aws.region_name}:{aws.account}:*"
],
'aws_ses_identity_policy': { # TODO: Move pol to lambda_policy
'notify': {
'identity': '${aws_ses_domain_identity.notify.arn}',
'name': config.qualified_resource_name('notify'),
'policy': {
'Version': '2012-10-17',
'Statement': [
{
'Effect': 'Allow',
'Principal': {
'AWS': 'arn:aws:sts::'
+ aws.account
+ ':assumed-role/'
+ '${aws_lambda_function.' + 'app.name' + '.function_name}/'
# The following is the role-session-name of the principal
# assuming the role via an AWS STS AssumeRole operation.
+ '${aws_lambda_function.' + '_'.join(['app.name', 'notify'])
+ '.function_name}'
},
'Action': [
'ses:SendEmail',
'ses:SendRawEmail'
],
'Resource': '${aws_ses_domain_identity.notify.arn}',
}
]
}
}
}
},
*(
[
{
Expand Down
21 changes: 21 additions & 0 deletions src/azul/indexer/notification_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import chalice.app

from azul import (
cached_property,
)
from azul.chalice import (
AppController,
)
from azul.indexer.notify_service import (
EmailService,
)


class MonitoringController(AppController):

@cached_property
def email_service(self):
return EmailService()

def notify_group(self, event: chalice.app.SNSEvent) -> None:
self.email_service.send_message(event.subject, event.message)
57 changes: 57 additions & 0 deletions src/azul/indexer/notify_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import json
import logging

from azul import (
config,
)
from azul.deployment import (
aws,
)
from azul.strings import (
trunc_ellipses,
)

log = logging.getLogger(__name__)


class EmailService:

@property
def to_email(self):
return config.monitoring_email

@property
def from_email(self):
return ' '.join([
'Azul',
config.deployment_stage,
'Monitoring',
'<monitoring@' + config.api_lambda_domain('indexer') + '>'
])

def send_message(self, subject: str, body: str) -> None:
log.info('Sending message %r with body %r',
subject, trunc_ellipses(body, 256))
try:
body = json.loads(body)
except json.decoder.JSONDecodeError:
log.warning('Not a JSON serializable event, sending as is')
else:
body = json.dumps(body, indent=4)
content = {
'Simple': {
'Subject': {
'Data': subject
},
'Body': {
'Text': {
'Data': body
}
}
}
}
response = aws.ses.send_email(FromEmailAddress=self.from_email,
Destination=dict(ToAddresses=[self.to_email]),
Content=content)
log.info('Successfully sent message %r, message ID is %r',
subject, response['MessageId'])
24 changes: 23 additions & 1 deletion terraform/api_gateway.tf.json.template.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,20 @@ def for_domain(cls, domain):
}
})
} for i, domain in enumerate(app.domains)
}
},
**(
{
'notify': {
'zone_id': '${data.aws_route53_zone.%s.id}' % zones_by_domain[app.domains[0]].slug,
'name': '_amazonses.' + app.domains[0],
'type': 'TXT',
'ttl': '600',
'records': ['${aws_ses_domain_identity.notify.verification_token}']
}
}
if app.name == 'indexer' and config.enable_monitoring else
{}
)
},
'aws_cloudwatch_log_group': {
app.name: {
Expand Down Expand Up @@ -667,6 +680,15 @@ def for_domain(cls, domain):
}
)
},
**(
{
'aws_ses_domain_identity': {
'notify': {
'domain': app.domains[0]
}
}
} if app.name == 'indexer' and config.enable_monitoring else {}
),
**(
{
'aws_lb': {
Expand Down

0 comments on commit a2a362b

Please sign in to comment.