Thursday, November 21, 2019

Simple CICD with CodeBuild

CICD is awesome. It automates the tasks we want to perform whenever we push code to our repository:

  • test linting
  • audit third party dependencies for security
  • compile the code
  • run unit tests
  • publish artifacts

There are a lot of great options for implementing CICD too. On-prem systems like Jenkins and Team City, and SAAS systems like Travis, CircleCI, and Github Actions. For my personal projects I wanted a SAAS solution that triggers on github PR's and integrates with AWS, so I setup a CodeBuild process that I'm pretty happy with.

Codebuild Setup

Setting up codebuild is straight forward if you're comfortable with cloudformation.

  • save credentials
  • setup IAM roles for codebuild
  • setup a build for each github repository
  • add a build conifiguration to each github repository

save credentials

Codebuild can access credentials in AWS secrets manager for interacting with non-AWS systems like github and npm.org. The misc-stuff github repo has helpers for common operations tasks like managing secrets.

git clone https://github.com/frickjack/misc-stuff.git
export LITTLE_HOME="$(pwd)/misc-stuff"
alias little="bash '$(pwd)/misc-stuff/AWS/little.sh'"

little help secret
little secret create the/secret/key "$(cat secretValue.json)"

codebuild IAM setup

Codebuild needs IAM credentials to allocate build resources. This cloudformation template sets up a standard role that we'll pass to our builds:

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Parameters": {
      "GithubToken": {
        "Type": "String",
        "Description": "arn of secretsmanager secret for access token",
        "ConstraintDescription": "secret arn",
        "AllowedPattern": "arn:.+"
      }
    },
    "Resources": {
      "CodeBuildRole": {
        "Type": "AWS::IAM::Role",
        "Properties": {
          "RoleName" : "littleCodeBuild",
          "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [{
              "Effect": "Allow",
              "Principal": { "Service": ["codebuild.amazonaws.com"] },
              "Action": ["sts:AssumeRole"]
            }]
          },
          "Policies": [{
            "PolicyName": "CodebuildPolicy",
            "PolicyDocument": {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Sid": "CloudWatchLogsPolicy",
                  "Effect": "Allow",
                  "Action": [
                    "logs:CreateLogGroup",
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                  ],
                  "Resource": [
                    "*"
                  ]
                },
                {
                  "Sid": "CodeCommitPolicy",
                  "Effect": "Allow",
                  "Action": [
                    "codecommit:GitPull"
                  ],
                  "Resource": [
                    "*"
                  ]
                },
                {
                  "Sid": "S3Policy",
                  "Effect": "Allow",
                  "Action": [
                    "s3:GetObject",
                    "s3:GetObjectVersion",
                    "s3:PutObject",
                    "s3:GetBucketAcl",
                    "s3:GetBucketLocation"
                  ],
                  "Resource": [
                    "*"
                  ]
                },
                {
                  "Sid": "SsmPolicy",
                  "Effect": "Allow",
                  "Action": [
                    "ssm:GetParameters",
                    "secretsmanager:Get*"
                  ],
                  "Resource": [
                    "*"
                  ]
                },
                {
                  "Sid": "ECRPolicy",
                  "Effect": "Allow",
                  "Action": [
                    "ecr:BatchCheckLayerAvailability",
                    "ecr:GetDownloadUrlForLayer",
                    "ecr:BatchGetImage",
                    "ecr:GetAuthorizationToken"

                  ],
                  "Resource": [
                    "*"
                  ]
                },
                {
                  "Sid": "CFPolicy",
                  "Effect": "Allow",
                  "Action": [
                    "cloudformation:ValidateTemplate"
                  ],
                  "Resource": [
                    "*"
                  ]
                },
                {
                  "Sid": "LambdaLayerPolicy",
                  "Effect": "Allow",
                  "Action": [
                    "lambda:PublishLayerVersion",
                    "lambda:Get*",
                    "lambda:List*",
                    "lambda:DeleteLayerVersion",
                    "iam:List*"
                  ],
                  "Resource": [
                    "*"
                  ]
                }
              ]
            }
          }
        ]}
      },
      "GithubCreds": {
        "Type" : "AWS::CodeBuild::SourceCredential",
        "Properties" : {
            "AuthType" : "PERSONAL_ACCESS_TOKEN",
            "ServerType" : "GITHUB",
            "Token": { "Fn::Join" : [ "", [ "{{resolve:secretsmanager:", { "Ref": "GithubToken" }, ":SecretString:token}}" ]] }
          }
      }
    },

    "Outputs": {
    }
}

We can use the little tool mentioned earlier to deploy the stack:

little stack create "$LITTLE_HOME/AWS/db/cloudformation/YourAccount/cell0/cicd/cicdIam/stackParams.json"

register a new build

This cloudformation template registers a new build that runs for pull requests and tag events on a github repository. In addition to the primary repo the build also pulls in a secondary support repository that hosts our automation scripts.

{
    "AWSTemplateFormatVersion": "2010-09-09",
    "Parameters": {
      "ProjectName": {
        "Type": "String",
        "Description": "name of the build project - also tag",
        "ConstraintDescription": "should usually be the domain of the github repository"
      },
      "ServiceRole": {
        "Type": "String",
        "Description": "arn of IAM role for codebuild to assume",
        "ConstraintDescription": "IAM role arn",
        "AllowedPattern": "arn:.+"
      },
      "GithubRepo": {
        "Type": "String",
        "Description": "url of github source repo",
        "ConstraintDescription": "https://github.com/ repo url",
        "AllowedPattern": "https://github.com/.+"
      },
      "SupportRepo": {
        "Type": "String",
        "Description": "url of github secondary repo - default https://github.com/frickjack/misc-stuff.git",
        "Default": "https://github.com/frickjack/misc-stuff.git",
        "ConstraintDescription": "https://github.com/ repo url",
        "AllowedPattern": "https://github.com/.+"
      }
    },

    "Resources": {
        "CodeBuild": {
            "Type" : "AWS::CodeBuild::Project",
            "Properties" : {
                "Artifacts" : {
                  "Type": "NO_ARTIFACTS"
                },
                "BadgeEnabled" : true,
                "Description" : "build and test little-elements typescript project",
                "Environment" : {
                  "ComputeType" : "BUILD_GENERAL1_SMALL",
                  "EnvironmentVariables" : [ 
                    {
                      "Name" : "LITTLE_EXAMPLE",
                      "Type" : "PLAINTEXT",
                      "Value" : "ignore"
                    }
                   ],
                  "Image" : "aws/codebuild/standard:2.0",
                  "Type" : "LINUX_CONTAINER"
                },
                "Name" : { "Ref": "ProjectName" },
                "QueuedTimeoutInMinutes" : 30,
                "SecondaryArtifacts" : [],
                "ServiceRole" : { "Ref": "ServiceRole" },
                "Source" : {
                  "Type": "GITHUB",
                  "Location": { "Ref" : "GithubRepo" },
                  "GitCloneDepth": 2,
                  "ReportBuildStatus": true
                },
                "SecondarySources": [
                  {
                    "Type": "GITHUB",
                    "Location": { "Ref" : "SupportRepo" },
                    "GitCloneDepth": 1,
                    "SourceIdentifier": "HELPERS"
                  }
                ],
                "Tags": [
                    {
                        "Key": "org",
                        "Value": "applications"
                    },
                    {
                        "Key": "project",
                        "Value": { "Ref": "ProjectName" }
                    },
                    {
                        "Key": "stack",
                        "Value": "cell0"
                    },
                    {
                        "Key": "stage",
                        "Value": "dev"
                    },
                    {
                      "Key": "role",
                      "Value": "codebuild"
                    }
                ],
                "TimeoutInMinutes" : 10,
                "Triggers" : {
                  "FilterGroups" : [ 
                    [
                      {
                        "ExcludeMatchedPattern" : false,
                        "Pattern" : "PULL_REQUEST_CREATED, PULL_REQUEST_UPDATED, PULL_REQUEST_REOPENED, PULL_REQUEST_MERGED",
                        "Type" : "EVENT"
                      }
                    ],
                    [
                        {
                          "ExcludeMatchedPattern" : false,
                          "Pattern" : "PUSH",
                          "Type" : "EVENT"
                        },
                        {
                          "ExcludeMatchedPattern" : false,
                          "Pattern" : "^refs/tags/.*",
                          "Type" : "HEAD_REF"
                        }
                    ]
                   ],
                  "Webhook" : true
                }
            }
        }
    },

    "Outputs": {
    }
}

We can use the little tool mentioned earlier to deploy the stack:

little stack create "$LITTLE_HOME/AWS/db/cloudformation/YourAccount/cell0/cicd/nodeBuild/little-elements/stackParams.json"

configure the repository

Codebuild expects a buildspec.yaml file in the code repository to contain the commands for a build. This build file runs a typescript compile, unit tests, dependency security audit, and linting check. If the build trigger is a tagging event, then the build goes on to publish the build's assets as a lambda layer and https://npm.org package.

# see https://docs.aws.amazon.com/codepipeline/latest/userguide/tutorials-codebuild-devicefarm.html
version: 0.2

env:
  variables:
    LITTLE_INTERACTIVE: "false"
  parameter-store:
    NPM_TOKEN: "/aws/reference/secretsmanager/applications/cicd/cell0/dev/npmjs-token"
phases:
  install:
    runtime-versions:
      nodejs: 10
    commands:
      - echo "Entered the install phase - jq already installed ..."
      #- apt-get update -y
      #- apt-get install -y jq
  pre_build:
    commands:
      - echo "HOME is $HOME, CODEBUILD_SRC_DIR is $CODEBUILD_SRC_DIR, CODEBUILD_SRC_DIR_HELPERS is $CODEBUILD_SRC_DIR_HELPERS, pwd is $(pwd)"
      - echo "//registry.npmjs.org/:_authToken=$(echo "$NPM_TOKEN" | jq -e -r .token)" > "$HOME/.npmrc"
      - mkdir -p "$HOME/.aws"; /bin/echo -e "[default]\nregion = us-east-2\noutput = json\ncredential_source = Ec2InstanceMetadata\n" | tee "$HOME/.aws/config"
      - npm ci
      - pip install yq --upgrade
  build:
    commands:
      - npm run build
      - npm run lint
      - npm audit --audit-level=high
      - npm run test
  post_build:
    commands:
      - echo "CODEBUILD_WEBHOOK_TRIGGER == $CODEBUILD_WEBHOOK_TRIGGER"
      # checkout a branch, so lambda publish goes there
      - git checkout -b "cicd-$CODEBUILD_WEBHOOK_TRIGGER"
      - BUILD_TYPE="$(echo $CODEBUILD_WEBHOOK_TRIGGER | awk -F / '{ print $1 }')"
      - echo "BUILD_TYPE is $BUILD_TYPE"
      # publish lambda layers for pr's and tags
      - if test "$BUILD_TYPE" = pr || test "$BUILD_TYPE" = tag; then bash "$CODEBUILD_SRC_DIR_HELPERS/AWS/little.sh" lambda update "$CODEBUILD_SRC_DIR"; fi
      # publish on tag events - see https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html
      - if test "$BUILD_TYPE" = tag; then npm publish . --tag cicd; fi

Saturday, November 16, 2019

Publish Web Content to S3

I host a little website at https://apps.frickjack.com by publishing static web files to an S3 bucket behind a cloudfront CDN. One way to improve the performance of an S3 web site is to configure each file's (s3 object) content encoding (gzip'ing the file if required), content type, and cache control at upload time (S3 natively specifies an Etag).

Here's a little script that makes that easy (also at https://github.com/frickjack/misc-stuff/blob/master/AWS/bin/s3web.sh), but rather than copy the script - you can install it like this:

git clone https://github.com/frickjack/misc-stuff.git
alias little="bash $(pwd)/Code/misc-stuff/AWS/little.sh"

little help s3web
little s3web publish localFolder s3Path [--dryrun]

Given a local source folder with html and other web content, then little s3web publish localFolder s3://destiation does the following:

  • html files are gzipped, and annotated with headers content-encoding gzip, mime-type, cache for one hour
  • css, js, svg, map, json, md files are gzipped, and annotated with content-encoding, mime-type, and cache for 1000 hours
  • png, jpg, wepb files are not gzipped, and annotated with mime-type and cache for 1000 hours

This caching policy assumes that html files may periodically change to reference new assets (javascript, css, etc), but the referenced assets are immutable. For example, https://apps.frickjack.com loads javascript and CSS assets from a versioned subpath, and every update to https://apps.frickjack.com publishes a new folder of assets.

#
# helpers for publishing web content to S3
#

source "${LITTLE_HOME}/lib/bash/utils.sh"

# lib ------------------------

#
# Determine mime type for a particular file name
#
# @param path
# @return echo mime type
#
s3webPath2ContentType() {
    local path="$1"
    shift
    case "$path" in
        *.html)
            echo "text/html; charset=utf-8"
            ;;
        *.json)
            echo "application/json; charset=utf-8"
            ;;
        *.js)
            echo "application/javascript; charset=utf-8"
            ;;
        *.mjs)
            echo "application/javascript; charset=utf-8"
            ;;
        *.css)
            echo "text/css; charset=utf-8"
            ;;
        *.svg)
            echo "image/svg+xml; charset=utf-8"
            ;;
        *.png)
            echo "image/png"
            ;;
        *.jpg)
            echo "image/jpeg"
            ;;
        *.webp)
            echo "image/webp"
            ;;
        *.txt)
            echo "text/plain; charset=utf-8"
            ;;
        *.md)
            echo "text/markdown; charset=utf-8"
            ;;
        *)
            echo "text/plain; charset=utf-8"
            ;;
    esac
}

#
# Get the npm package name from the `package.json`
# in the current directory
#
s3webPublish() {
    local srcFolder
    local destPrefix
    local dryRun=off

    if [[ $# -lt 2 ]]; then
        gen3_log_err "s3web publish takes 2 arguments"
        little help s3web
        return 1
    fi
    srcFolder="$1"
    shift
    destPrefix="$1"
    shift
    if [[ $# -gt 0 && "$1" =~ ^-*dryrun ]]; then
        shift
        dryRun=on
    fi
    if [[ ! -d "$srcFolder" ]]; then
        gen3_log_err "invalid source folder: $srcFolder"
        return 1
    fi
    if ! [[ "$destPrefix" =~ ^s3://.+ ]]; then
        gen3_log_err "destination prefix should start with s3:// : $destPrefix"
        return 1
    fi
    local filePath
    local ctype
    local encoding
    local cacheControl
    local errCount=0
    local gzTemp
    local commandList
    (
        commandList=()
        cd "$srcFolder"
        gzTemp="$(mktemp "$XDG_RUNTIME_DIR/gzTemp_XXXXXX")"
        find . -type f -print | while read -r filePath; do
            ctype="$(s3webPath2ContentType "$filePath")"
            encoding="gzip"
            cacheControl="max-age=3600000, public"
            destPath="${destPrefix%/}/${filePath#./}"
            if [[ "$ctype" =~ ^image/ && ! "$ctype" =~ ^image/svg ]]; then
                encoding=""
            fi
            if [[ "$ctype" =~ ^text/html || "$ctype" =~ ^application/json ]]; then
                cacheControl="max-age=3600, public"
            fi

            if [[ "$encoding" == "gzip" ]]; then
                gzip -c "$filePath" > "$gzTemp"
                commandList=(aws s3 cp "$gzTemp" "$destPath" --content-type "$ctype" --content-encoding "$encoding" --cache-control "$cacheControl")
            else
                commandList=(aws s3 cp "$filePath" "$destPath" --content-type "$ctype" --cache-control "$cacheControl")
            fi
            gen3_log_info "dryrun=$dryRun - ${commandList[@]}"
            if [[ "$dryRun" == "off" ]]; then
                "${commandList[@]}" 1>&2
            fi
            errCount=$((errCount + $?))
            if [[ -f "$gzTemp" ]]; then
                /bin/rm "$gzTemp"
            fi
        done
        [[ $errCount == 0 ]]
    )
}

# main ---------------------

# GEN3_SOURCE_ONLY indicates this file is being "source"'d
if [[ -z "${GEN3_SOURCE_ONLY}" ]]; then
    command="$1"
    shift

    case "$command" in
        "content-type")
            s3webPath2ContentType "$@"
            ;;
        "publish")
            s3webPublish "$@"
            ;;
        *)
            little help s3web
            ;;
    esac
fi