Reading time: 6 min

Setting up Slack Notifications For CodePipeline

When you're working with AWS CodePipeline(/CodeBuild/StepFunctions) daily, it can be very useful to receive notifications to slack (or other messaging[...]

 

Table of Contents

When you're working with AWS CodePipeline(/CodeBuild/StepFunctions) daily, it can be very useful to receive notifications to slack (or other messaging services) of when the pipeline has finished, instead of watching it or periodically coming back to it.

In this post, I'll walk through how I've previously setup Slack Notifications using AWS EventBridge, Lambda and SecretsManager.

 

EventBridge

AWS EventBridge is the service that allows you to trigger actions when specific events occur. The below CloudFormation allowed me to create 2 EventBridge Rules. One for CodePipeline and the other for StepFunctions. 

  PipelineRunNotification:
    Type: AWS::Events::Rule
    Properties:
      Description: Pipeline Run Notification
      Name: PipelineRunNotification
      State: ENABLED
      EventPattern:
        source:
          - aws.codepipeline
        detail-type:
          - CodePipeline Pipeline Execution State Change
        detail:
          state:
            - FAILED
            - STOPPED
            - SUCCEEDED
            - CANCELED
            - STARTED
            - RESUMED
        resources:
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Infrastructure-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Application-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Frontend-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-IOS-Develop-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-Android-Develop-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-IOS-Release-Candidate-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-Android-Release-Candidate-Pipeline
      Targets:
        - Arn: !GetAtt SlackNotificationLambda.Arn
          Id: PipelineRunNotification
          
  StepFunctionsExecutionNotification:
    Type: AWS::Events::Rule
    Properties:
      Description: Step Functions Execution Notification
      Name: StepFunctionsExecutionNotification
      State: ENABLED
      EventPattern:
        source:
          - aws.states
        detail-type:
          - Step Functions Execution Status Change
        detail:
          status:
            - RUNNING
            - SUCCEEDED
            - FAILED
            - TIMED_OUT
            - ABORTED
          stateMachineArn:
            - !Sub arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:ReleaseStepFunction
      Targets:
        - Arn: !GetAtt SlackNotificationLambda.Arn

 

SecretsManager

I stored the slack web url in secrets manager to avoid misuse of it. 

 

Slack

In slack I created a specific channel, then created an app which resulted in a url that I could post to which would result in slack messages.

Lambda

The below code shows the jist of how the notifications can be sent

import http.client
import json
import boto3
import logging
import os

logger = logging.getLogger()

# Set the log level from an environment variable or default to ERROR
log_level = os.getenv('LOG_LEVEL', 'ERROR').upper()
logger.setLevel(log_level)

# Create a console handler
handler = logging.StreamHandler()

# Create a formatter and set it for the handler
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)

# Add the handler to the logger
logger.addHandler(handler)

def send_slack_message(webhook_url :str, message :str, user :str, icon_emoji :str, fields :list, color :str) -> None:
def get_codepipeline_execution_information(event :dict) -> dict:
def get_slack_notification_url(json_key :str = 'default') -> str:
def handle_codepipeline_event(event :dict) -> str:
def handle_eventbridge_event(event :dict) -> str:
def handle_cloudformation_event(event :dict) -> str:
def handle_cli_event(event :dict) -> str:
def handle_step_function_event(event: dict) -> str:

def handler(event, context):
    logger.info(f"Event: {event} \n Context: {context}")
     # Determine the source of the event and extract the message
    if 'source' in event and event['source'] == 'aws.codepipeline':
        handle_codepipeline_event(event)
    elif 'source' in event and event['source'] == 'aws.events':
        handle_eventbridge_event(event)
    elif 'RequestType' in event:  # CloudFormation Custom Resource
        handle_cloudformation_event(event)
    elif 'detail' in event and 'aws-cli' in event['detail']:
        handle_cli_event(event)
    elif 'source' in event and event['source'] == "aws.states":
        handle_step_function_event(event)


if __name__ == '__main__':
    handler(None, None)

send_slack_message

def send_slack_message(webhook_url :str, message :str, user :str, icon_emoji :str, fields :list, color :str) -> None:
    # Parse the webhook URL to get the host and path
    logger.info(f"Sending message to Slack: {message}")
    url_parts = webhook_url.split("/", 3)
    host = url_parts[2]
    path = "/" + url_parts[3]

    # Create a connection object
    conn = http.client.HTTPSConnection(host)

    # Headers for the request
    headers = {
        'Content-type': 'application/json'
    }

    # Data to be sent in the POST request
    payload = json.dumps({
        "text": message,
        "username": user,
        "icon_emoji": icon_emoji,
        "color": color,
        "fields": fields
    })

    # Send a POST request
    conn.request("POST", path, body=payload, headers=headers)

    # Get the response
    response = conn.getresponse()

    # Read and decode the response
    data = response.read().decode()

    # Print the status and response
    logger.info(f"Status: {response.status}")
    logger.info(f"Response: {data}")

    # Close the connection
    conn.close()

get_slack_notification_url

def get_slack_notification_url(json_key :str = 'default') -> str:
    """
        Gets the Slack notification URL from AWS Secrets Manager
        Pass in the key (channel type) to get the URL
    """
    client = boto3.client('secretsmanager')
    response = client.get_secret_value(SecretId='slack-notification-urls')
    raw_secret = response['SecretString']
    secret_json = json.loads(raw_secret)
    return secret_json[json_key]    

handle_codepipeline_event

def handle_codepipeline_event(event :dict) -> str:
    logger.info(f"Handling CodePipeline event: {event}")
    url = get_slack_notification_url('default')
    codepipeline_detail = get_codepipeline_execution_information(event)
    logger.info(f"Lambda Triggered by {codepipeline_detail.get('trigger')}")
    pipeline = codepipeline_detail.get("pipeline")
    env = os.environ['Environment'].upper()
    state = event.get('detail').get('state')
    if 'build-orchestrator' in codepipeline_detail.get('trigger'):
        user = "Build Orchestrator"
    elif '/Administrator/' in codepipeline_detail.get('trigger'):
        user = f"{env} Administrator"
    else:
        user = codepipeline_detail.get('trigger').split('/')[-1]
    pipeline = codepipeline_detail.get("pipeline")
    env = os.environ['Environment'].upper()
    state = event.get('detail').get('state')
    message = f"{pipeline} in {env} triggered by {user} is in a {state} state"
    fields = [
        {"title": "Pipeline", "value": pipeline, "short": True},
        {"title": "State", "value": state, "short": True},
        {"title": "ExecutionId", "value":  codepipeline_detail.get("execution_id"), "short": True},
        {"title": "Environment", "value": env, "short": True}
    ]
    if event.get('detail').get('state') in ("FAILED",  "CANCELLED"):
        color = "bad"
    else:
        color = "good"
        
    icon_dict = {
        "Application-Pipeline": ":rocket:",
        "Infrastructure-Pipeline": ":factory:",
        "Frontend-Pipeline": ":globe_with_meridians:",
        "Mobile-App-IOS-Develop-Pipeline": ":iphone:",
        "Mobile-App-Android-Develop-Pipeline": ":phone:",
        "Mobile-App-IOS-Release-Candidate-Pipeline": ":iphone:",
        "Mobile-App-Android-Release-Candidate-Pipeline": ":phone:"
    }
        
    send_slack_message(url, str(message), user, icon_dict.get(pipeline), fields, color)

handle_step_function_event

def handle_step_function_event(event: dict) -> str:
    logger.info(f"Handling Step Function Event")
    logger.info(f"Event Info: {event}")
    url = get_slack_notification_url('default')
    env = os.environ['Environment'].upper()
    state = event.get('detail').get('status')
    release_name = event.get('detail').get('name')
    message = f"Release Step Function execution of {release_name} in {env} is in a {state} state"
    input_value = json.loads(event.get('detail').get('input'))

    fields = [
        {"title": "Release", "value": input_value.get('release_version'), "short": True},
        {"title": "State", "value": state, "short": True},
        {"title": "Hotfix", "value":  input_value.get('hotfix'), "short": True},
        {"title": "MobileRelease", "value":  input_value.get('run_app_pipelines'), "short": True},
        {"title": "Environment", "value": env, "short": True}
    ]

    if event.get('detail').get('status') in ("FAILED",  "TIMED_OUT", "ABORTED"):
        color = "bad"
    else:
        color = "good"

    if event.get('detail').get('error') is not None:
        fields.append({"title": "Error", "value": event.get('detail').get('error'), "short": False})
    
    if event.get('detail').get('cause') is not None:
        fields.append({"title": "Cause", "value": event.get('detail').get('cause'), "short": False})

    send_slack_message(webhook_url=url, message=message, user="Release Step Function", icon_emoji=":cool-doge:", fields=fields, color=color)
    

 

 

CloudFormation

Lambda

SlackNotificationLambda:
    Type: AWS::Lambda::Function
    Properties:
      Handler: !Sub ${FileName}.handler
      Role: !GetAtt SlackNotificationLambdaRole.Arn
      Code:
          S3Bucket: !Ref DevOpsBucketName
          S3Key: !Sub app-infra/zipped_lambdas/${FileName}.zip
      Runtime: "python3.11"
      Timeout: 30
      MemorySize: 128
      Description: "Lambda function to send slack notification"
      Environment:
        Variables:
          AccountId: !Sub ${AWS::AccountId}
          Environment: !Ref Environment
          DevOpsBucketName: !Ref DevOpsBucketName
      Tags:
        - Key: "CostCenter"
          Value: !Ref CostCenter
        - Key: "Environment"
          Value: !Ref Environment

IAM

SlackNotificationLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
           -  PolicyName: SlackNotificationLambdaPolicy
              PolicyDocument: 
               Version: '2012-10-17'
               Statement:
                 - Effect: Allow
                   Action:
                     - secretsmanager:GetSecretValue
                   Resource: 
                     - !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:slack-notification-urls*
           - PolicyName: CodePipelinePolicy
             PolicyDocument:
               Version: '2012-10-17'
               Statement:
                 - Effect: Allow
                   Action: 
                     - codepipeline:GetPipeline
                     - codepipeline:GetPipelineState
                     - codepipeline:GetPipelineExecution
                   Resource:
                     - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Infrastructure-Pipeline
                     - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Application-Pipeline
                     - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Frontend-Pipeline
                     - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-IOS-Develop-Pipeline
                     - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-Android-Develop-Pipeline
                     - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-IOS-Release-Candidate-Pipeline
                     - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-Android-Release-Candidate-Pipeline
                     
           - PolicyName: StepFunctionsPolicy
             PolicyDocument:
               Version: '2012-10-17'
               Statement:
                 - Effect: Allow
                   Action:
                     - states:DescribeExecution
                     - states:DescribeStateMachine
                   Resource:
                     - !Sub arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:ReleaseStepFunction-ZrbGymeGn9iR

Permissions

  LambdaInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref SlackNotificationLambda
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt PipelineRunNotification.Arn
  
  LambdaInvokePermissionStepFunction:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref SlackNotificationLambda
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt StepFunctionsExecutionNotification.Arn

EventBridge

PipelineRunNotification:
    Type: AWS::Events::Rule
    Properties:
      Description: Pipeline Run Notification
      Name: PipelineRunNotification
      State: ENABLED
      EventPattern:
        source:
          - aws.codepipeline
        detail-type:
          - CodePipeline Pipeline Execution State Change
        detail:
          state:
            - FAILED
            - STOPPED
            - SUCCEEDED
            - CANCELED
            - STARTED
            - RESUMED
        resources:
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Infrastructure-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Application-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Frontend-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-IOS-Develop-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-Android-Develop-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-IOS-Release-Candidate-Pipeline
          - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:Mobile-App-Android-Release-Candidate-Pipeline
      Targets:
        - Arn: !GetAtt SlackNotificationLambda.Arn
          Id: PipelineRunNotification
          
  StepFunctionsExecutionNotification:
    Type: AWS::Events::Rule
    Properties:
      Description: Step Functions Execution Notification
      Name: StepFunctionsExecutionNotification
      State: ENABLED
      EventPattern:
        source:
          - aws.states
        detail-type:
          - Step Functions Execution Status Change
        detail:
          status:
            - RUNNING
            - SUCCEEDED
            - FAILED
            - TIMED_OUT
            - ABORTED
          stateMachineArn:
            - !Sub arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:ReleaseStepFunction
      Targets:
        - Arn: !GetAtt SlackNotificationLambda.Arn
          Id: StepFunctionsExecutionNotification