Using CloudFormation to Automate Build, Test, and Deploy with CodePipeline

jennapederson

Jenna Pederson

Posted on December 8, 2021

Using CloudFormation to Automate Build, Test, and Deploy with CodePipeline

In part 1, we automated the provisioning of an Amazon EC2 instance using AWS CloudFormation. In part 2, we added an Amazon RDS Postgresql database to the CloudFormation template from part 1 so both the EC2 instance and the database can be provisioned together as a set of resources. Today in part 3, we will introduce a continuous integration/continuous deployment (CI/CD) pipeline to automate the build, test, and deploy phases of your release process. To do this, we’ll use AWS CodeDeploy and CodePipeline.

As a reminder, here's what we've covered and where we're going:

  • automate the provisioning of your EC2 instance using CloudFormation (part 1),
  • add an RDS Postgresql database to your stack with CloudFormation (part 2), and
  • create a CodePipeline with CloudFormation (this post, part 3).

Prerequisites

To work through the examples in this post, you’ll need:

  • an AWS account (you can create your account here if you don’t already have one),
  • the AWS CLI installed (you can find instructions for installing the AWS CLI here), and
  • a key-pair to use for SSH (you can create a key-pair following these instructions).

Unfamiliar with CloudFormation or feeling a little rusty? Check out part 1 or my Intro to CloudFormation post before getting started.

Just want the code? Grab it here and then check out the buildspec, appspec, and scripts here.

Prepare EC2 Instance

First, we’ll need to prepare the EC2 instance so that we can deploy our app to it. We’ll create an EC2 instance role and a CodeDeploy trust role, install the CodeDeploy agent, and tag the instance or instance we want to deploy to.

1. Create EC2 Instance Role

In the CloudFormation template that creates your EC2 instance, create the following new resources, InstanceRole, InstanceRolePolicies, and InstanceRoleInstanceProfile:

InstanceRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Statement:
        - Effect: Allow
          Principal:
            Service:
              - ec2.amazonaws.com
          Action:
            - sts:AssumeRole
    Path: /

InstanceRolePolicies:
  Type: AWS::IAM::Policy
  Properties:
    PolicyName: InstanceRole
    PolicyDocument:
      Statement:
        - Effect: Allow
          Action:
            - autoscaling:Describe*
            - cloudformation:Describe*
            - cloudformation:GetTemplate
            - s3:Get*
          Resource: '*'
    Roles:
      - !Ref 'InstanceRole'

InstanceRoleInstanceProfile:
  Type: AWS::IAM::InstanceProfile
  Properties:
    Path: /
    Roles:
      - !Ref 'InstanceRole'
Enter fullscreen mode Exit fullscreen mode

These resources create an instance profile to pass an IAM role to an EC2 instance. This allows the EC2 instance to do things like get the CodeDeploy agent from S3.

Next, we’ll add the IamInstanceProfile property to the EC2 instance:

WebAppInstance:
  Properties:
    ...
    IamInstanceProfile: !Ref 'InstanceRoleInstanceProfile'
Enter fullscreen mode Exit fullscreen mode

2. Install CodeDeploy Agent

The CodeDeploy agent will be installed on each EC2 instance you want to deploy your app to and helps CodeDeploy communicate with your EC2 instance for deployments. First, you’ll include metadata in the AWS::CloudFormation::Init key, which will be used by the cfn-init helper script.

WebAppInstance:
  ...
  Metadata:
    AWS::CloudFormation::Init:
      services:
        sysvint:
          codedeploy-agent:
            enabled: 'true'
            ensureRunning: 'true'
Enter fullscreen mode Exit fullscreen mode

Then, we’ll add the UserData key, which allows us to pass user data to the EC2 instance to perform automated configuration tasks and run scripts after the instance starts up.

WebAppInstance:
    Properties:
        ...
        UserData: !Base64
          Fn::Join:
            - ''
            - - "#!/bin/bash -ex\n"
              - "yum update -y aws-cfn-bootstrap\n"
              - "yum install -y aws-cli\n"
              - "yum install -y ruby\n"
              - "iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 3000\n"
              - "echo 'iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 3000' >> /etc/rc.local\n"
              - "# Helper function.\n"
              - "function error_exit\n"
              - "{\n"
              - '  /opt/aws/bin/cfn-signal -e 1 -r "$1" '''
              - !Ref 'WaitHandle'
              - "'\n"
              - "  exit 1\n"
              - "}\n"
              - "# Install the AWS CodeDeploy Agent.\n"
              - "cd /home/ec2-user/\n"
              - "aws s3 cp 's3://aws-codedeploy-us-east-1/latest/codedeploy-agent.noarch.rpm'\
                \ . || error_exit 'Failed to download AWS CodeDeploy Agent.'\n"
              - "yum -y install codedeploy-agent.noarch.rpm || error_exit 'Failed to\
                \ install AWS CodeDeploy Agent.' \n"
              - '/opt/aws/bin/cfn-init -s '
              - !Ref 'AWS::StackId'
              - ' -r WebAppInstance --region '
              - !Ref 'AWS::Region'
              - " || error_exit 'Failed to run cfn-init.'\n"
              - "# All is well, so signal success.\n"
              - /opt/aws/bin/cfn-signal -e 0 -r "AWS CodeDeploy Agent setup complete."
                '
              - !Ref 'WaitHandle'
              - "'\n"
Enter fullscreen mode Exit fullscreen mode

This installs a few helper packages like the aws-cli and aws-cfn-bootstrap, and then installs the CodeDeploy agent (by copying it from S3). The cfn-init script grabs the metadata we added earlier and ensures those services are enabled and running. The cfn-signal helper script signals to CloudFormation that the instance had been successfully created or updated.

Finally, add the following two resources that are used in the UserData we just added so that CloudFormation waits until the UserData scripts are finished running.

WaitHandle:
  Type: AWS::CloudFormation::WaitConditionHandle
WaitCondition:
  Type: AWS::CloudFormation::WaitCondition
  Properties:
    Handle: !Ref 'WaitHandle'
    Timeout: '900'
Enter fullscreen mode Exit fullscreen mode

3. Tag the Instances

Next, we need to tag the EC2 instances. CodeDeploy will use these tags to identify which instances to deploy to.

WebAppInstance:
  Properties:
    ...
    Tags:
      - Key: 'CodeDeployTag'
        Value: 'CodeDeployDemo'
Enter fullscreen mode Exit fullscreen mode

4. Create CodeDeploy Trust Role

We also need to add a CodeDeploy trust role so that CodeDeploy has access to work with the EC2 instance. Add the following two resources:

CodeDeployTrustRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Statement:
        - Sid: '1'
          Effect: Allow
          Principal:
            Service:
              - codedeploy.us-east-1.amazonaws.com
              - codedeploy.us-west-2.amazonaws.com
          Action: sts:AssumeRole
    Path: /
CodeDeployRolePolicies:
  Type: AWS::IAM::Policy
  Properties:
    PolicyName: CodeDeployPolicy
    PolicyDocument:
      Statement:
        - Effect: Allow
          Resource:
            - '*'
          Action:
            - ec2:Describe*
        - Effect: Allow
          Resource:
            - '*'
          Action:
            - autoscaling:CompleteLifecycleAction
            - autoscaling:DeleteLifecycleHook
            - autoscaling:DescribeLifecycleHooks
            - autoscaling:DescribeAutoScalingGroups
            - autoscaling:PutLifecycleHook
            - autoscaling:RecordLifecycleActionHeartbeat
    Roles:
      - !Ref 'CodeDeployTrustRole'
Enter fullscreen mode Exit fullscreen mode

This trust role will be used in the next section when we configure the CodePipeline. We’ll pass the ARN of this trust role to that CloudFormation template, so let’s add this as an Output:

Outputs:
  ...
  CodeDeployTrustRoleARN:
    Value: !GetAtt 'CodeDeployTrustRole.Arn'
Enter fullscreen mode Exit fullscreen mode

5. Create the stack

And finally, we’ll need to create the stack:

aws cloudformation create-stack --stack-name CloudFormationEc2Example --template-body file://07_ec2_codedeploy.yaml \
--parameters ParameterKey=AvailabilityZone,ParameterValue=us-east-1a \
ParameterKey=EnvironmentType,ParameterValue=dev \
ParameterKey=KeyPairName,ParameterValue=jenna \
ParameterKey=DBPassword,ParameterValue=Abcd1234 \
--capabilities CAPABILITY_IAM
Enter fullscreen mode Exit fullscreen mode

Or, if you’re updating the stack you created in part 2, you can use the update-stack command instead.

Once we’ve created the EC2 instance set up for CodeDeploy, we’ll be ready to create the CodePipeline pipeline.

To view the full version of this template, check it out here.

Create CodePipeline pipeline

The pipeline we are building will have three stages:

  • A Source stage to pull our code from the GitHub repository. The Source stage will use a CodeStarConnection and an S3 bucket.

  • A Build stage to build the source code into an artifact. The Build stage will use a CodeBuild Project and the same S3 bucket.

  • A Deploy stage to deploy the artifact to the EC2 instance. The Deploy stage will use a CodeDeploy Application and a CodeDeploy DeploymentGroup.

First, we’ll need an app to deploy.

1. Prepare the App to Deploy

You can fork the hello-express repo into your own github account. This is a simple Node/Express web app. For the purposes of this demo, the most interesting parts are the buildspec.yml and appspec.yml files, and scripts in the bin directory:

  • The buildspec.yml file is a specification file that contains build commands and configuration that are used to build a CodeBuild project.
  • The appspec.yml file is a specification file that defines a series of lifecycle hooks for a CodeDeploy deployment.
  • The bin directory contains the www start script for the app, and the scripts for each of the lifecycle hooks. You can read more about the lifecycle hooks here.

2. Add Parameters

Then, we’ll need the following input Parameters for our template:

Parameters:
  GitHubRepo:
    Type: String

  GitHubBranch:
    Type: String
    Default: main

  GitHubUser:
    Type: String

  CodeDeployServiceRole:
    Type: String
    Description: A service role ARN granting CodeDeploy permission to make calls to EC2 instances with CodeDeploy agent installed.

  TagKey:
    Description: The EC2 tag key that identifies this as a target for deployments.
    Type: String
    Default: CodeDeployTag
    AllowedPattern: '[\x20-\x7E]*'
    ConstraintDescription: Can contain only ASCII characters.
  TagValue:
    Description: The EC2 tag value that identifies this as a target for deployments.
    Type: String
    Default: CodeDeployDemo
    AllowedPattern: '[\x20-\x7E]*'
    ConstraintDescription: Can contain only ASCII characters.
Enter fullscreen mode Exit fullscreen mode

3. Create Service Roles

In order for CodeBuild to access S3 to put the built artifact into the bucket, we'll need to create a service role, CodeBuildServiceRole. We’ll need a second service role, CodePipelineServiceRole, which allows CodePipeline to get the source code from the GitHub connection, to start builds, to get the artifact from the bucket, and to create and deploy the app. Add these two IAM resources:

CodeBuildServiceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: "logs"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
                -
                  Effect: "Allow"
                  Action:
                      - logs:CreateLogGroup
                      - logs:CreateLogStream
                      - logs:PutLogEvents
                      - ecr:GetAuthorizationToken
                      - ssm:GetParameters
                  Resource: "*"
        - PolicyName: "S3"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
                -
                  Effect: "Allow"
                  Action:
                      - s3:GetObject
                      - s3:PutObject
                      - s3:GetObjectVersion
                  Resource: !Sub arn:aws:s3:::${ArtifactBucket}/*

  CodePipelineServiceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: codepipeline.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: root
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Resource:
                  - !Sub arn:aws:s3:::${ArtifactBucket}/*
                  - !Sub arn:aws:s3:::${ArtifactBucket}
                Effect: Allow
                Action:
                  - s3:*
              - Resource: "*"
                Effect: Allow
                Action:
                  - codebuild:StartBuild
                  - codebuild:BatchGetBuilds
                  - iam:PassRole
              - Resource:
                  - !Ref CodeStarConnection
                Effect: Allow
                Action:
                  - codestar-connections:UseConnection
              - Resource: "*"
                Effect: Allow
                Action:
                  - codedeploy:CreateDeployment
                  - codedeploy:CreateDeploymentGroup
                  - codedeploy:GetApplication
                  - codedeploy:GetApplicationRevision
                  - codedeploy:GetDeployment
                  - codedeploy:GetDeploymentConfig
                  - codedeploy:RegisterApplicationRevision
Enter fullscreen mode Exit fullscreen mode

4. Create the Source Stage

For the Source stage, we’ll create a CodeStar Connection and an S3 Bucket.

1. Create a CodeStarConnection

When we set up the pipeline, we’ll have a Source stage the pulls our source code from GitHub. To do this, we’ll need to create a CodeStarConnection for GitHub. This will give our pipeline access to a GitHub repository. We’ll use CloudFormation to create this by adding the resource to our template, but there will be a manual step to change the connection from Pending to Available after the first time we apply the template.

CodeStarConnection:
    Type: 'AWS::CodeStarConnections::Connection'
    Properties:
      ConnectionName: CfnExamplesGitHubConnection
      ProviderType: GitHub
Enter fullscreen mode Exit fullscreen mode

2. Create S3 Bucket to Hold Artifacts

We’ll need a place to store the build artifacts so we’ll create an S3 bucket. Add the following S3 resource to your template:

ArtifactBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
Enter fullscreen mode Exit fullscreen mode

3. Create the Stage

Then we’ll create the first stage of the pipeline.

Pipeline:
  Type: AWS::CodePipeline::Pipeline
  Properties:
    RoleArn: !GetAtt CodePipelineServiceRole.Arn
    ArtifactStore:
      Type: S3
      Location: !Ref ArtifactBucket
    Stages:
      - Name: Source
        Actions:
          - Name: App
            ActionTypeId:
              Category: Source
              Owner: AWS
              Version: '1'
              Provider: CodeStarSourceConnection
            Configuration:
              ConnectionArn: !Ref CodeStarConnection
              BranchName: !Ref GitHubBranch
              FullRepositoryId: !Sub ${GitHubUser}/${GitHubRepo}
            OutputArtifacts:
              - Name: AppArtifact
            RunOrder: 1
Enter fullscreen mode Exit fullscreen mode

Using the CodeStarSourceConnection resource we created above, this will configure it to use the branch, GitHub user, and repository name based on the input parameters.

5. Create Build Stage

For the Build stage, we’ll create a CodeBuild Project that indicates what kind of environment to build the code in. Here, we’re using a Docker Linux container. We’ll also set the service role to the service role we created earlier.

CodeBuildProject:
  Type: AWS::CodeBuild::Project
  Properties:
    Artifacts:
      Type: CODEPIPELINE
    Source:
      Type: CODEPIPELINE
      BuildSpec: buildspec.yml
    Environment:
      ComputeType: BUILD_GENERAL1_SMALL
      Image: aws/codebuild/docker:17.09.0
      Type: LINUX_CONTAINER
    Name: !Ref AWS::StackName
    ServiceRole: !Ref CodeBuildServiceRole
Enter fullscreen mode Exit fullscreen mode

Then we need to add the Build stage to the pipeline we started in the last step.

- Name: Build
  Actions:
    - Name: Build
      ActionTypeId:
        Category: Build
        Owner: AWS
        Version: '1'
        Provider: CodeBuild
      Configuration:
        ProjectName: !Ref CodeBuildProject
      InputArtifacts:
        - Name: AppArtifact
      OutputArtifacts:
        - Name: BuildOutput
      RunOrder: 1
Enter fullscreen mode Exit fullscreen mode

This will also use the same S3 bucket from a the previous step to store the build output.

6. Create Deploy Stage

In the last stage, Deploy, we’ll need a CodeDeploy Application and a CodeDeploy DeploymentGroup.

CodeDeployApplication:
  Type: AWS::CodeDeploy::Application

CodeDeployGroup:
  Type: AWS::CodeDeploy::DeploymentGroup
  Properties:
    ApplicationName: !Ref CodeDeployApplication
    Ec2TagFilters:
      - Key: !Ref 'TagKey'
        Value: !Ref 'TagValue'
        Type: KEY_AND_VALUE
    ServiceRoleArn: !Ref 'CodeDeployServiceRole'
Enter fullscreen mode Exit fullscreen mode

The DeploymentGroup uses the EC2TagFilters to specify which group of EC2 instances to deploy to. When we setup the EC2 instance above, we tagged it with a tag/value that is used here. We also set the service role to the one we created earlier.

Then we add the final stage to the pipeline.

- Name: Deploy
  Actions:
    - Name: Deploy
      ActionTypeId:
        Category: Deploy
        Owner: AWS
        Version: '1'
        Provider: CodeDeploy
      Configuration:
        ApplicationName: !Ref CodeDeployApplication
        DeploymentGroupName: !Ref CodeDeployGroup
      InputArtifacts:
        - Name: BuildOutput
      RunOrder: 1
Enter fullscreen mode Exit fullscreen mode

7. Add Outputs

Last, we need to add an Output to our template to give us the fully URL to view our pipeline in the AWS Console.

Outputs:
  PipelineUrl:
    Value: !Sub https://console.aws.amazon.com/codepipeline/home?region=${AWS::Region}#/view/${Pipeline}
Enter fullscreen mode Exit fullscreen mode

To view the full version of this template, check it out here.

8. Create the Stack

Now that we have the pipeline template created, we can create the stack. We’ll need to copy CodeDeployTrustRoleARN Output from previous EC2 stack, which you can grab from the Outputs tab in the AWS Console.

CloudFormation Stack Outputs tab in the AWS Console

Then, run create-stack at the command line, replacing CODE_DEPLOY_SERVICE_ROLE_ARN below with the ARN you just copied.

aws cloudformation create-stack --stack-name CloudFormationPipelineExample --template-body file://08_pipeline.yaml \
--parameters ParameterKey=GitHubRepo,ParameterValue=hello-express \
ParameterKey=GitHubUser,ParameterValue=jennapederson \
ParameterKey=CodeDeployServiceRole,ParameterValue=CODE_DEPLOY_SERVICE_ROLE_ARN \
--capabilities CAPABILITY_IAM
Enter fullscreen mode Exit fullscreen mode

Because this template creates IAM roles, we also need to tell CloudFormation that this capability (creating IAM resources) is allowed to be used by specifying the --capabilities option.

9. Make CodeStarConnection Available

Once the stack is created successfully, you'll need to change the CodeStarConnection from Pending to Available. To do this, head over to the AWS Console and find your newly created stack. On the Outputs tab, click the link to go to your new pipeline.

CloudFormation Stack Outputs tab in the AWS Console

In the left-hand menu under Settings, click Connections. Select the new connection and click the "Update pending connection" button.

CodeStar Connection Settings

You'll need to give GitHub access to your account and the repository before the connection will become available. You can read more about that here in the section "To create a connection to GitHub."

10. Retry the Source Stage

Now that your CodeStarConnection is Available, head back to your pipeline and note that the Source stage has failed because of the Pending CodeStarConnection.

CodePipeline pipeline showing a Source stage failure

Click the "Retry" button next to the Source stage to restart it.

CodePipeline pipeline showing the Retry button for the failed Source stage

Your pipeline will restart by pulling the source code from GitHub, build the app, and then deploy the artifact to your EC2 instance! When complete, you can grab the WebServerPublicDNS URL from your EC2 stack and open it in a browser:

CloudFormation Stack Outputs tab in the AWS Console

And you will see Hello, Express show in your browser!

The Express app running in the browser

What you learned

In this post, we enhanced the CloudFormation template from part 2 to install CodeDeploy agent and tag the EC2 instances you want to deploy to. Then we created a new template that sets up a Source, Build, and Deploy stage for a CodePipeline complete with a CodeStarConnection, CodeBuild project, and a CodeDeploy application. Now you have a pipeline that will pull source code, build your app, and deploy it to EC2 when there are changes to a specific branch in a GitHub repository.

You can grab the final CloudFormation template we created here.

Like what you read? Follow me here on Dev.to or on Twitter to stay updated!

💖 💪 🙅 🚩
jennapederson
Jenna Pederson

Posted on December 8, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related