Automating Static Website Deployment, Part 2

by AlphaGeek

Sun, Jun 25, 2017


This is a continuation of a series of articles started in Part 1.

In this installment we will be discussing how I construct the CodeBuild buildspec.yml, and the CodeCommit trigger that will invoke it. First, however, I will discuss the shortcommings of CodePipeline and CodeDeploy that made them inappropriate for this usecase.

Why Not CodePipeline & CodeDeploy?

You’d think just from the names that these two services from AWS would fit the bill. When you read the descriptions of these services you’d say, “Wow, this is the way to go!” You’d be wrong, however. What we are trying to do is develop a completely serverless static website hosting, see Hosting a Static Website for Pennies a Month for more detail. CodeDeploy is geared towards deploying code to a compute resource of some sort. It is perfect for deploying compiled code to a compute resource like AWS Lamnda, EC2 Instance, or ECS. However, there is no way to take a tarball or zip file and expand it to an S3 bucket.

OK, then why not use the CodePipeline to trigger the build at least instead of going to the trouble of creating lambda function to do it? Well there we run into a problem. I use git submodules for a couple of reasons:

  1. To include the theme(s) I need for my website.
  2. To share a directory of website management tools, which I am writing about in this article, with all of my website repos.

This is a problem because CodePipeline does not deliver a git repo it delivers a zip file that is expanded before executing the build steps. This causes at least two problems:

  1. The submodules cannot be initialized and updated.
  2. The executable bit on various scripts is not set correctly.

Therefore, CodePipeline is not a viable tool for my use case. I did see some forum posts that indicate AWS is looking at rectifying this but I have no ETA on when it will be fixed.

AWS CodeCommit, Lambda, and CodeBuild to the rescue!

Since a CodeCommit trigger can invoke a Lambda function, a CodeBuild can be started using an AWS cli command, and CodeBuild can execute tasks like syncing the output directory of Hugo with my S3 bucket, I thought that these services would be an easy way to build a solution.

As I detail in Serverless Go Web Services using AWS it’s quite easy to write Lambda functions in Go using eawsy/aws-lambda-go. Since they have restructured the project since I wrote that article, we are actually going to use eawsy/aws-lambda-go-core.

The Lambda Function

The Lambda is really straitforward. To make it general purpose, so you can use it for as many websites as you have, I pass the name of the CodeBuild project in the “customData” string in the JSON that is delivered when the build is triggered. The message format is reflected in the structs I define in the source below. The documentation is not that great so I wrote the first version of this to just echo the event data to the console so that it would show up in the CloudWatch logs for the Lambda function and used that as my guide. I also set the BRANCH_NAME environment variable on the build so that the build can use that to know how to build and deploy the site.

package main

import (
  "encoding/json"
  "fmt"

  "github.com/aws/aws-sdk-go/aws"
  "github.com/aws/aws-sdk-go/aws/session"
  "github.com/aws/aws-sdk-go/service/codebuild"
  "github.com/eawsy/aws-lambda-go-core/service/lambda/runtime"
)

type CodeCommitReference struct {
  Commit string
  Ref    string
}

type CodeCommit struct {
  References []CodeCommitReference
}

type CodeCommitRecord struct {
  EventId              string
  EventVersion         string
  EventTime            string
  EventPartNumber      int
  Codecommit           CodeCommit
  EventName            string
  EventTriggerConfigId string
  EventSourceARN       string
  UserIdentityARN      string
  AwsRegion            string
  EventTotalParts      int
  CustomData           string
}

type CodeCommitMessage struct {
  Records []CodeCommitRecord
}

func Handle(evt json.RawMessage, ctx *runtime.Context) (interface{}, error) {
  var message CodeCommitMessage

  err := json.Unmarshal(evt, &message)
  if err != nil {
    fmt.Printf("error parsing message: %s", err.Error())
    return nil, err
  }

  config := aws.NewConfig()

  sess, err := session.NewSession(config)
  if err != nil {
    fmt.Printf("error creating AWS session: %s", err.Error())
    return nil, err
  }

  codeBuild := codebuild.New(sess)

  for _, cbr := range message.Records {
    startBuildInput := codebuild.StartBuildInput{
      ProjectName:   aws.String(cbr.CustomData),
      SourceVersion: aws.String(cbr.Codecommit.References[0].Commit),
      EnvironmentVariablesOverride: []*codebuild.EnvironmentVariable{
        {Name: aws.String("BRANCH_NAME"), Value: aws.String(cbr.Codecommit.References[0].Ref)},
      },
    }
    startBuildOutput, err := codeBuild.StartBuild(&startBuildInput)
    if err != nil {
      fmt.Printf("Error starting a build for %s: %s", cbr.CustomData, err.Error())
    } else {
      fmt.Printf("Build started: %+v", startBuildOutput)
    }
  }
  return nil, nil
}

To build the lamnda function I have the following Makefile. Docker is used to do the build since I do all my development on OS X. If you are using Linux you may want to simplify this but I really like using containers for builds. It makes them more predicatble across machines.

HANDLER ?= handler
PACKAGE ?= $(HANDLER)
GOPATH  ?= $(HOME)/go

WORKDIR = $(CURDIR:$(GOPATH)%=/go%)
ifeq ($(WORKDIR),$(CURDIR))
  WORKDIR = /build
endif

docker:
  @docker run --rm                           \
    -e HANDLER=$(HANDLER)                    \
    -e PACKAGE=$(PACKAGE)                    \
    -v $(GOPATH):/go                         \
    -v $(CURDIR):/build                      \
    -w $(WORKDIR)                            \
    eawsy/aws-lambda-go-shim:latest make all

.PHONY: docker

all: build pack perm

.PHONY: all

build:
  @go build -buildmode=plugin -ldflags='-w -s' -o $(HANDLER).so

.PHONY: build

pack:
  @pack $(HANDLER) $(HANDLER).so $(PACKAGE).zip

.PHONY: pack

perm:
  @chown $(shell stat -c '%u:%g' .) $(HANDLER).so $(PACKAGE).zip

.PHONY: perm

clean:
  @rm -rf $(HANDLER).so $(PACKAGE).zip

.PHONY: clean

deploy:
  @aws lambda update-function-code --function-name codebuildtrigger --zip-file fileb://handler.zip

The deploy target is used to update the function code but first you will need to have the function created. You need to create a role for this lambda function to assume that grants the codebuild:StartBuild permission.

$ aws iam create-role --path /service-role/ --role-name codebuildtrigger-role \
    --assume-role-policy-document file://$(f=$(mktemp); cat <<JSON >$f
{
  "Version": "2012-10-17",
  "Statement": {
    "Action": "sts:AssumeRole",
    "Effect": "Allow",
    "Principal": {
      "Service": "lambda.amazonaws.com"
    }
  }
}    
JSON
echo $f)

The “assume-role-policy-document” gives the AWS Lambda service permission to assume this role. Now we apply the permissions to the role that we desire.

$ aws iam put-role-policy --role-name codebuildtrigger-role \
    --policy-name allow-start-build \
    --policy-document file://$(f=$(mktemp); cat <<JSON >$f
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "codebuild:StartBuild"
      ],
      "Resource": [
        "*"
      ],
      "Effect": "Allow",
      "Sid": "Stmt1495996635000"
    }
  ]
}   
JSON
echo $f)

And, finally, we create the function with the handler.zip that we generated from our build.

$ aws lambda create-function --function-name codebuildtrigger \
    --runtime python2.7 --handler handler.Handle --zip-file ./handler.zip \
    --role arn:aws:iam::##########:role/service-role/codebuildtrigger-role

Create the CodeBuild Project

Now that we have an AWS Lambda function that can kick off our builds we need to create the CodeBuild project. Before we create the project we need to write our buildspec.yml. This file will execute the build steps and allow us to contain most, if not all, the build configuration and logic in the source code for our site. The

version: 0.2

phases:
  install:
    commands:
    - ${CODEBUILD_SRC_DIR}/scripts/install.sh
  pre_build:
    commands:
    - ${CODEBUILD_SRC_DIR}/scripts/pre_build.sh
  build:
    commands:
    - ${CODEBUILD_SRC_DIR}/scripts/build.sh
  post_build:
    commands:
    - ${CODEBUILD_SRC_DIR}/scripts/post_build.sh
artifacts:
  files:
  - public-*.tar.gz

I like to keep the buildspec.yml very simple and rely on sh to do the heavy scripting. The commands in the buildspec.yml are simply executed one after the other and the build fails if any of them return non-zero exit codes. The phases are fixed and they include ‘install’, ‘pre_build’, ‘build’, and ‘post_build’. The artifacts section tells CodeBuild what bits to store away for latter. You don’t have to have any artifacts, the build can just complete without them but I like to save off the generated site. When we create the CodeBuild project we will specify an S3 bucket to store the builds in.

The install phase should be used to install any software you need. I presume that if you have lots of builds running this might be used to make builds more efficient but I have never run enough builds back-to-back to see it not execute the install step. In any case here is what my install script contains.

#!/bin/sh
apt-get update -y && apt-get install awscli jq python-pygments -y
aws configure set preview.cloudfront true
wget -q https://github.com/spf13/hugo/releases/download/v0.21/hugo_0.21_Linux-64bit.deb
dpkg -i hugo*.deb
git config --global credential.helper '!aws codecommit credential-helper $@'
git config --global credential.UseHttpPath true

Since we use an ubuntu image as the base image for the build, installs are done with apt-get. Hugo is not available in apt-get, as far as I know, so we install it by retrieving the .deb file and using dpkg. The git config calls are needed to setup the credential helper since the pre_build script needs to run some git commands. We use a shared “set_environment.sh” script to help make sure the environment is set correctly in all the scripts starting with pre_build.sh.

#!/bin/sh

if [ -z "${BRANCH_NAME}" ]; then
   BRANCH_NAME="$(git rev-parse --symbolic-full-name HEAD)"
fi

BRANCH_SHORT_NAME="$(echo "${BRANCH_NAME}" | sed 's|^refs/heads/||g')"

Right now it just makes sure the BRANCH_NAME variable is set. This is so that I can test in the local environment since BRANCH_NAME will be set in all the triggered builds. Now here is the “pre_build.sh”

#!/bin/sh
DIR="$(cd $(dirname "${0}"); pwd)"

. "${DIR}/set_environment.sh"

git submodule update --init

This is needed because we use a git submodule for the theme. Next here is the build.sh. It, basically, contrusts the “BUILD_OPTS” variable to set the options passed to the build script we created in Part 1 of this series.

#!/bin/sh

DIR="$(cd $(dirname "${0}"); pwd)"

. "${DIR}/set_environment.sh"

BUILD_DIR=$(cd "${DIR}/.."; pwd)


if [ -z "${BRANCH_SHORT_NAME}" ] ; then
  VERSION="$(git rev-parse HEAD | cut -c 1-7)"
else 
  VERSION="${BRANCH_SHORT_NAME}-$(git rev-parse HEAD | cut -c 1-7)"
fi

BUILD_OPTS="-b ${BUILD_DIR} -v ${VERSION}"
case "${BRANCH_SHORT_NAME}" in
  master)
    echo "No drafts for master."
    ;;
  *)
    BUILD_OPTS="-D ${BUILD_OPTS} -d qa.computersfearme.com"
    ;;
esac

"${BUILD_DIR}/website-tools/build.sh" $BUILD_OPTS

The BRANCH_NAME wragling that I am doing here is so that I can have unique tarball names for the artifact spec we have in the buildspec.yml. If you don’t need the artifacts to be saved you can get rid of this. The last part before the call to website-tools/build.sh is where we add the ability to have different branches go to different domains. I use git-flow so master is for production and develop is for QA. I don’t setup triggers for any feature branch.

The ‘post_build.sh’ script does the deploy:

#!/bin/sh

DIR="$(cd $(dirname "${0}"); pwd)"

. "${DIR}/set_environment.sh"

BUILD_DIR=$(cd "${DIR}/.."; pwd)

case "${BRANCH_SHORT_NAME}" in
  master)
    "${BUILD_DIR}/website-tools/deploy.sh" -b "${BUILD_DIR}/public" -h origin.computersfearme.com -d XXXXXXXXXXXXXXX
    exit $?
    ;;
  develop)
    "${BUILD_DIR}/website-tools/deploy.sh" -b "${BUILD_DIR}/public" -h origin.qa.computersfearme.com -d XXXXXXXXXXXXXXX
    exit $?
    ;;
  *)
    echo "Skipping deploy for feature branch"
    ;;
esac

Once we have all these script in place and set to be executable. We can create our CodeBuild project. We start by creating a role for the code build to assume. This role must have permissions to do any AWS operations that your scripts want to do. In our case, we need to be able to push the code to the origin buckets and issue an invalidation against the CloudFront distribution.

$ aws iam create-role --path /service-role/ --role-name cfm-codebuild-role \
    --assume-role-policy-document file://$(f=$(mktemp); cat <<JSON >$f
{
  "Version": "2012-10-17",
  "Statement": {
    "Action": "sts:AssumeRole",
    "Effect": "Allow",
    "Principal": {
      "Service": "codebuild.amazonaws.com"
    }
  }
}
JSON
echo $f)

The “assume-role-policy-document” is a policy document that gives the “codebuild.amasonaws.com” service permission to assume this role. Now we will apply the policy allowing access to S3 and CloudFront.

$ aws iam put-role-policy --role-name cfm-codebuild-role \
    --policy-name allow-deploy-and-invalidation \
    --policy-document file://$(f=$(mktemp); cat <<JSON >$f
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "s3:*"  
      ],
      "Resource": [
        "arn:aws:s3:::origin.computersfearme.com/*"
      ],
      "Effect": "Allow",
      "Sid": "Stmt1495742932000"
    },
    {
      "Action": [
        "s3:*"
      ],
      "Resource": [
        "arn:aws:s3:::origin.qa.computersfearme.com/*"
      ],
      "Effect": "Allow",
      "Sid": "Stmt1497118462000"
    },
    {
      "Action": [
        "cloudfront:CreateInvalidation",
        "cloudfront:GetInvalidation"
      ],
      "Resource": [
        "*"
      ],
      "Effect": "Allow",
      "Sid": "Stmt1495742167000"
    }            
  ]
}   
JSON
echo $f)

Now we can create the CodeBuild project.

$ aws codebuild create-project --name computersfearme-build \
    --source type=CODECOMMIT,location=https://git-codecommit.us-east-1.amazonaws.com/v1/repos/computersfearme.com \
    --artifacts type=S3,location=website-builds.bluesoftdev.com,name=comptuersfearme.com \
    --environment type=LINUX_CONTAINER,computeType=BUILD_GENERAL1_SMALL,image=aws/codebuild/ubuntu-base:14.04,privilegeMode=false \
    --service-role cfm-codebuild-role

Now that we have the code build we can trigger our first build.

$ git commit -am "added buildspec.yml and build scripts"
$ git push
$ aws codebuild start-build --project-name computersfearme-build

This will trigger the build on the default branch. Now we need to create the trigger in CodeCommit to kick off the Lamnda that will start the CodeBuild build on push. We are using git-flow so we are going to push master to production and develop to QA. Keep in mind, the API for creating triggers replaces all of the triggers so you have to include the settings for all of the triggers you want to keep and the ones you are adding. For our new CodeCommit repository and our new CodeBuild project, there will be one trigger so we do not need to worry too much about that. There is an api for getting the current triggers.

$ aws codecommit put-repository-triggers --repository-name computersfearme.com \
    --triggers file://$(f=$(mktemp); cat <<JSON >$f
[
  {
    "name": "build-on-push",
    "destinationArn": "arn:aws:lambda:us-east-1:228007608367:function:awsbuildtrigger"
    "branches": ["master","develop"],
    "events": [ "updateReference" ],
    "customData": "computersfearme-build"
  }
]
JSON
echo $f)

In the next installment, I will be discussing how to use CloudFormation to orchestrate this entire setup.