diff --git a/.gitignore b/.gitignore index 8f389d8..d67e633 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store *.pyc slack.py cf-notify.zip +vendor diff --git a/README.md b/README.md index 5df6b5c..d104347 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ # CF Notify ## What? -An AWS Lambda function that will post Cloud Formation status updates to a Slack channel via a Slack Web Hook. +An AWS Lambda function that will post Cloud Formation status updates to a Slack channel via a Slack Web Hook. Additionally it will notify you of stacks that do not have notifications enabled. ## Why? To give visibility of Cloud Formation changes to the whole team in a quick and simple manner. For example: -![example Slack messages](./example.jpeg) +![example Slack messages](./misc/example.jpeg) ## How? CF Notify has a stack of AWS resources consisting of: - An SNS Topic - - A Lambda function, which uses the SNS Topic as an event source + - CloudWatch Rule + - A Lambda function, which uses the SNS Topic and CloudWatch Scheduled Event as event sources - An IAM Role to execute the Lambda function We add the SNS Topic of CF Notify to the notification ARNs of the Stack we want to monitor. @@ -35,15 +36,17 @@ You can create an incoming webhook [here](https://my.slack.com/services/new/inco This is done using the script [deploy.sh](./deploy.sh). ```sh -./deploy.sh $CHANNEL $WEBHOOK $AWS_PROFILE +./deploy.sh $CHANNEL $WEBHOOK $BUCKET $TOPIC $AWS_PROFILE ``` Where: - - CHANNEL is the Slack channel or user to send messages to. It will be used in the naming of the Lambda artifact file stored in S3. + - CHANNEL is the Slack channel or user to send messages to. - WEBHOOK is the Web Hook URL of an Incoming Web Hook (see https://api.slack.com/incoming-webhooks). - - AWS_PROFILE is the aws cli profile you want to use for deploy. Default profile is "default" + - BUCKET is the S3 bucket where the Lambda artifacts are deployed. + - TOPIC is the SNS topic name. + - AWS_PROFILE is the aws cli profile you want to use for deploy. The default profile is "default". -`deploy.sh` will create a zip file and upload it to S3 and also create a cloud formation stack using the [template](./cf-notify.json). +`deploy.sh` will create a zip file and upload it to S3 and also create a cloud formation stack using the [template](./cloudformation/cf-notify.json). ## Usage diff --git a/cf-notify.json b/cloudformation/cf-notify.json similarity index 56% rename from cf-notify.json rename to cloudformation/cf-notify.json index d835931..bd19c8c 100644 --- a/cf-notify.json +++ b/cloudformation/cf-notify.json @@ -2,10 +2,26 @@ "AWSTemplateFormatVersion": "2010-09-09", "Description": "cf notify stack", "Parameters": { - "Bucket": { + "Release": { + "Description": "The release/version of cf-notify", + "Type": "String" + }, + "ArtifactBucket": { "Description": "S3 bucket to locate lambda function (cf-notify.zip)", "Type": "String" - } + }, + "TopicName": { + "Description": "The SNS topic name used by cf-notify", + "Type": "String" + }, + "SlackChannel": { + "Description": "The Slack channel that notifications are delivered to", + "Type": "String" + }, + "SlackWebhook": { + "Description": "The Slack webhook used to handle Slack integration", + "Type": "String" + } }, "Resources": { "CFNotifyRole": { @@ -42,9 +58,17 @@ { "Effect": "Allow", "Action": [ + "cloudformation:DescribeStacks", "cloudformation:DescribeStackResources" ], - "Resource": "arn:aws:cloudformation:*:*:*/*/*" + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "iam:ListUsers" + ], + "Resource": "*" } ] } @@ -60,7 +84,8 @@ "Endpoint": { "Fn::GetAtt": [ "CFNotifyFunction", "Arn" ] }, "Protocol": "lambda" } - ] + ], + "TopicName": { "Ref": "TopicName" } } }, "CFNotifyFunction": { @@ -72,14 +97,32 @@ "Fn::GetAtt": [ "CFNotifyRole", "Arn" ] }, "Code": { - "S3Bucket": { "Ref": "Bucket" }, - "S3Key": "cf-notify.zip" + "S3Bucket": { "Ref": "ArtifactBucket" }, + "S3Key": {"Fn::Join": ["/", [{ "Ref": "Release" }, "cf-notify.zip"]]} }, "Runtime": "python2.7", - "Timeout": "30" + "Timeout": "30", + "Environment": { + "Variables": { + "CHANNEL": { "Ref": "SlackChannel" }, + "WEBHOOK": { "Ref": "SlackWebhook" } + } + } + } + }, + "CFNotifyScheduledRule": { + "Type": "AWS::Events::Rule", + "Properties": { + "Description": "CF Notify check stacks for notification config", + "ScheduleExpression": "rate(12 hours)", + "State": "ENABLED", + "Targets": [{ + "Arn": { "Fn::GetAtt": ["CFNotifyFunction", "Arn"] }, + "Id": { "Fn::Join": ["", ["CFNotifyFunction", { "Ref": "Release" }]]} + }] } }, - "CFNotifyInvokePermission": { + "CFNotifySnsInvokePermission": { "Type": "AWS::Lambda::Permission", "Properties": { "FunctionName" : { "Ref" : "CFNotifyFunction" }, @@ -87,6 +130,15 @@ "Principal": "sns.amazonaws.com", "SourceArn": { "Ref": "CFNotifyTopic" } } + }, + "CFNotifyEventsInvokePermissions": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "FunctionName" : { "Ref" : "CFNotifyFunction" }, + "Action": "lambda:InvokeFunction", + "Principal": "events.amazonaws.com", + "SourceArn": { "Fn::GetAtt": ["CFNotifyScheduledRule", "Arn"] } + } } }, "Outputs": { diff --git a/deploy.sh b/deploy.sh index 6aa808e..f9a6914 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,20 +1,24 @@ #!/usr/bin/env bash -test $(which pwgen) -if [ $? != "0" ]; then - echo -e "pwgen not found. Please install using 'sudo apt-get install pwgen' (GNU/Linux) or 'brew install pwgen' (OSX)" - exit 1 -fi - if [ $# -lt 1 ] then - echo "usage: $0 " + echo "usage: $0 [TOPIC] [PROFILE]" exit 1 fi CHANNEL=$1 WEBHOOK=$2 -PROFILE=$3 +BUCKET=$3 +TOPIC=${4:-"cf-notify"} +PROFILE=${5:-"default"} + +RELEASE=$(date +%Y-%m-%d-%H%M) + +if [ -z $BUCKET ]; +then + echo "Please specify a bucket name"; + exit 1 +fi if [ -z $CHANNEL ]; then @@ -28,10 +32,6 @@ then exit 1 fi -if [ -z $PROFILE ]; -then - PROFILE="default" -fi if [[ $(aws configure --profile $PROFILE list) && $? -ne 0 ]]; then @@ -48,40 +48,36 @@ fi CHANNEL_NAME=`echo ${CHANNEL:1} | tr '[:upper:]' '[:lower:]'` -echo 'Creating bucket' -BUCKET="cf-notify-`pwgen -1 --no-capitalize 5`" -echo $BUCKET -aws s3 mb "s3://$BUCKET" --profile $PROFILE +echo "Creating bucket $BUCKET" +aws s3 mb "s3://$BUCKET" --profile $PROFILE || exit 1 echo "Bucket $BUCKET created" - echo 'Creating lambda zip artifact' - -if [ ! -f slack.py ]; then - cat > slack.py <'.format( + 'text': 'Stack: *{stack}* has entered status: *{status}* <{link}|(view in web console)>'.format( stack=cf_message['StackName'], status=cf_message['ResourceStatus'], link=stack_url), 'attachments': attachments } @@ -81,30 +118,42 @@ def get_stack_update_message(cf_message): return message +def get_channel(stack_name = None): + default = os.environ['CHANNEL'] if 'CHANNEL' in os.environ else None -def get_channel(stack_name): - default = slack.CHANNEL if hasattr(slack, 'CHANNEL') else None - - if hasattr(slack, 'CUSTOM_CHANNELS'): - return slack.CUSTOM_CHANNELS.get(stack_name, default) + try: + if hasattr(slack, 'CUSTOM_CHANNELS'): + return slack.CUSTOM_CHANNELS.get(stack_name, default) + except NameError: + pass return default - def get_stack_update_attachment(cf_message): - title = 'Stack {stack} is now status {status}'.format( - stack=cf_message['StackName'], - status=cf_message['ResourceStatus']) + fields = [ + { + 'title': 'ARN', + 'value': cf_message['StackId'] + }, + { + 'title': 'User', + 'value': resolve_user_id_to_name(cf_message['PrincipalId']), + 'short': True + }, + { + 'title': 'Timestamp', + 'value': cf_message['Timestamp'], + 'short': True + } + ] + + color = STATUS_COLORS.get(cf_message['ResourceStatus'], '#000000') return { - 'fallback': title, - 'title': title, - 'fields': [{'title': k, 'value': v, 'short': True} - for k, v in cf_message.iteritems() if k in SNS_PROPERTIES_FOR_SLACK], - 'color': STATUS_COLORS.get(cf_message['ResourceStatus'], '#000000'), + 'fields': fields, + 'color': color } - def get_stack_summary_attachment(stack_name): client = boto3.client('cloudformation') resources = client.describe_stack_resources(StackName=stack_name) @@ -121,12 +170,10 @@ def get_stack_summary_attachment(stack_name): for k, v in resource_count.iteritems()] } - def get_stack_region(stack_id): regex = re.compile('arn:aws:cloudformation:(?P[a-z]{2}-[a-z]{4,9}-[1-2]{1})') return regex.match(stack_id).group('region') - def get_stack_url(stack_id): region = get_stack_region(stack_id) @@ -138,3 +185,14 @@ def get_stack_url(stack_id): return ('https://{region}.console.aws.amazon.com/cloudformation/home?region={region}#/stacks?{query}' .format(region=region, query=urllib.urlencode(query))) + +def resolve_user_id_to_name(user_id): + client = boto3.client('iam') + response = client.list_users() + for user in response['Users']: + if user['UserId'] == user_id: + return user['UserName'] + return 'unknown (%s)' % user_id + +def is_debugging(): + return 'DEBUG' in os.environ and os.environ['DEBUG'] diff --git a/test/fixtures/scheduled_event.json b/test/fixtures/scheduled_event.json new file mode 100644 index 0000000..e6647e6 --- /dev/null +++ b/test/fixtures/scheduled_event.json @@ -0,0 +1,12 @@ +{ + "account": "123456789012", + "region": "us-east-1", + "detail": {}, + "detail-type": "Scheduled Event", + "source": "aws.events", + "time": "1970-01-01T00:00:00Z", + "id": "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c", + "resources": [ + "arn:aws:events:us-east-1:123456789012:rule/my-schedule" + ] +} diff --git a/test/fixtures/sns_cfn_event.json b/test/fixtures/sns_cfn_event.json new file mode 100644 index 0000000..2b7e0cd --- /dev/null +++ b/test/fixtures/sns_cfn_event.json @@ -0,0 +1,23 @@ +{ + "Records": [ + { + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:us-east-1:018935564540:cf-notify-CFNotifyTopic-1F5UJTJFS9YBY:feb1d7ff-69d6-4830-95c7-5ab33942c15f", + "EventSource": "aws:sns", + "Sns": { + "SignatureVersion": "1", + "Timestamp": "2017-04-29T00:55:00.110Z", + "Signature": "YchdPzNRyCzA3KS28+jISKN175JT5+h9Dosn5s/80eBeQ/z0DWSTfN9BBFs8dNQ1f8Tpy825d8ESiSaK1iLncAZOG9ohi9EOpOeSVSmWnHbAmobB0TN/Cb7pZDx5Vt2KKfhFyEV4wlFhtOK6isJE4Hf4luCKBOeHsF0/w3RmcKknpPsq6jLX3pMCyOb+uyxeRQd/vKFEfe0nh9cg/RlW9L0sEAU4T65PqpTT3G3wXJnBjY8VQm9vjngld6Cvzf1s1M/l+AYhtu00zrh6D0yvyphU8y8uEVZgm35bdjJtFMM+rvcHwyGuSAn9hqNo41kRZ0Bj0iZmiMtu/gDJ/JB7UA==", + "SigningCertUrl": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-b95095beb82e8f6a046b3aafc7f4149a.pem", + "MessageId": "f03806a5-f0fc-56bc-9d0a-57d816587b35", + "Message": "StackId='arn:aws:cloudformation:us-east-1:018935564540:stack/users/1e0e5d80-2c3b-11e7-b0eb-503aca2616c5'\nTimestamp='2017-04-29T00:55:00.045Z'\nEventId='78b39e90-2c76-11e7-8168-500c288f18d1'\nLogicalResourceId='users'\nNamespace='018935564540'\nPhysicalResourceId='arn:aws:cloudformation:us-east-1:018935564540:stack/users/1e0e5d80-2c3b-11e7-b0eb-503aca2616c5'\nPrincipalId='AIDAJHWZLD55O7222DXUM'\nResourceProperties='null'\nResourceStatus='UPDATE_COMPLETE'\nResourceStatusReason=''\nResourceType='AWS::CloudFormation::Stack'\nStackName='users'\nClientRequestToken='null'\n", + "MessageAttributes": { + }, + "Type": "Notification", + "UnsubscribeUrl": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:018935564540:cf-notify-CFNotifyTopic-1F5UJTJFS9YBY:feb1d7ff-69d6-4830-95c7-5ab33942c15f", + "TopicArn": "arn:aws:sns:us-east-1:018935564540:cf-notify-CFNotifyTopic-1F5UJTJFS9YBY", + "Subject": "AWS CloudFormation Notification" + } + } + ] +} diff --git a/test/test.py b/test/test.py new file mode 100755 index 0000000..8d5f9ad --- /dev/null +++ b/test/test.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python + +import os +import sys +import json + +sys.path.append("../src") +import lambda_notify + +if len(sys.argv) != 2: + print "usage: %s " % sys.argv[0] + print "ex: %s fixtures/scheduled_event.json" % sys.argv[0] + sys.exit(1) + +os.environ['DEBUG'] = 'true' + +with open(sys.argv[1]) as data_file: + event_data = json.load(data_file) + lambda_notify.lambda_handler(event_data, {})