Introduction Link to heading

aws-cdk was the new hotness at AWS re:Invent 2019, going from about 4 presentations in 2018 to something like 40 presentations. That’s an order of magnitude increase. And that is the kind of thing that gets my attention. cdk offers IaC that is… code. It’s not a dsl (looking at you, Terraform). Nor is it an attempt to overwhelm you with JSON or YAML verbosity. It’s… just… TypeScript. And that turns out to be powerful.

Getting Started Link to heading

If you haven’t already read Serving a Static Site with AWS, please take a moment and go read it. That’s what we will be building here.

cdk is available in python and other languages, but the libraries are written in TypeScript. I’ve really enjoyed learning and working in TypeScript, so that’s what we’ll use here. The standard getting started instructions apply:

# Install a global version of cdk... which you will only ever use to bootstrap new projects.
npm install -g aws-cdk

We also need to bootstrap cdk for your AWS account. This means that we’ll need to connect to your AWS account. cdk re-uses the same configuration that the aws cli tools use. We’ll start by confirming that the cli configuration is working. Note that I’m including --profile personal because my reality involves working with many AWS accounts. And… learning my way around multi-account stuff in cdk took a minute. If you have only one AWS account, you can omit the profile stuff. If you need to set up named profiles right away, check out Manage AWS CLI for multiple accounts.

# confirm that you have your aws CLI tool correctly set up
aws --profile personal sts get-caller-identity

# Needs to be run once for every account you're using cdk with.
# Sometimes needs to be re-run after pulling a cdk update.
cdk --profile personal bootstrap

Create the App Link to heading

mkdir hugo-cdk
cd hugo-cdk
# Going forward, you will only call cdk directly when init'ing a new project, like this.
# Once you've started a project, use `npx cdk` instead.
# That makes sure you're running the same version of cdk as the libraries you have installed.
cdk init --language typescript app

# And start building it. You might want to do this in the context of a terminal window in vscode so you can click to focus errors.
npm run watch

Add a Lambda Construct Link to heading

The architecture includes a Lambda function to rewrite Hugo requests to be CloudFront compliant. Turns out that there’s nothing project specific about this lambda, so we can re-use it across multiple hugo sites. Let’s make that the first thing we deploy. Pop over into your lib sub-directory and create a cloudfront-redirect.ts file. We’ll start with the basics, a construct to hold the lambda and related constructs. Most objects you deal with in cdk will be constructs.

import cdk = require('@aws-cdk/core');
import iam = require('@aws-cdk/aws-iam');
import lambda = require('@aws-cdk/aws-lambda');
import path = require('path');

export class CloudfrontRedirect extends cdk.Construct {
  constructor(scope: cdk.Construct, id: string) {
    super(scope, id);
  }
}

You can see a number of imports here, including the cdk core import. Let’s go ahead and install them since your build is probably failing.

npm install @aws-cdk/aws-iam @aws-cdk/aws-lambda

Core is where you will find all kinds of things from duration types through to the construct and stack types. Next we need to actually put something into this construct. Lambdas need to be called by someone. In order to decide who will be allowed to call them, we need to establish a trust relationship. This will consist of defining an IAM Role, and then granting the sts:AssumeRole permission to the principals who will be running the lambda.

export class CloudfrontRedirect extends cdk.Construct {
  constructor(scope: cdk.Construct, id: string) {
    super(scope, id);

    const role = new iam.Role(this, 'RedirectLambdaRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
    });
    role.assumeRolePolicy?.addStatements(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['sts:AssumeRole'],
        principals: [new iam.ServicePrincipal('edgelambda.amazonaws.com')],
      }),
    );
    ...
  }
}

You can see how incredibly easy it is to create a role. The other thing worth noting is that it is very easy to assign permission for the first principal by using the assumedBy property. After that, we need to explicitly add a statement to the policy. Because we’re going to have this function executed by CloudFront, we need these two service roles.

Ok, now we have a role, we can create the function.

export class CloudfrontRedirect extends cdk.Construct {
  public readonly fn: lambda.Function;
  public readonly version: lambda.Version;

  constructor(scope: cdk.Construct, id: string) {
    super(scope, id);

    const role = new iam.Role(this, 'RedirectLambdaRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
    });
    role.assumeRolePolicy?.addStatements(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['sts:AssumeRole'],
        principals: [new iam.ServicePrincipal('edgelambda.amazonaws.com')],
      }),
    );

    this.fn = new lambda.Function(this, 'RedirectLambda', {
      functionName: 'cloudfront-redirect',
      runtime: lambda.Runtime.NODEJS_12_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(path.join(__dirname, 'lambdas', 'cloudfront-redirect')),
      role: role,
    });
    new cdk.CfnOutput(this, 'TheRedirectLambda', { value: this.fn.functionArn });

    this.version = new lambda.Version(this, 'RedirectLambdaVersion', {
      lambda: this.fn,
    });
  }
}

That’s it. We’ve instantiated the redirect lambda. The required parameters are the runtime, the name of the handler, and the code. I’m using lambda.Code.fromAsset() to fetch the code from a subdirectory. We’ll get into the actual lambda code shortly. I’ve also passed in a functionName and the role we created previously.

I included a CloudFormation Output so that we can reference it elsewhere. And also because I kind of like to see the output.

Finally, we will create a version of the lambda. We’ll need this later for the CloudFront distribution.

Create a Lambda… In TypeScript! Link to heading

Let’s take a look at the actual code for the lambda. First off… this lambda is written in TypeScript. The same language as the cdk we are using to deploy it. This is a huge deal for me. I can pretty easily train JavaScript devs in TypeScript, heck I can probably hire devs who already know it. The entire npmjs stack they are already comfortable with still applies. In order to become self-serve, the only thing a dev needs to learn is the specifics of cdk. I can not think of any other IaC which can boast this.

I noticed that cdk has a pattern of using a lambdas directory and then having sub-directories for every lambda. This seems reasonable to me. Since this is a sub-directory of a TypeScript project, getting it compiled to js just comes along for free.

import awsLambda = require('aws-lambda');

function generateRedirectResponse(value: string): awsLambda.CloudFrontRequestResult {
  return {
    status: '301',
    statusDescription: 'Found',
    headers: {
      location: [
        {
          key: 'Location',
          value: value,
        },
      ],
    },
  };
}

export const handler: awsLambda.CloudFrontRequestHandler = (
  event: awsLambda.CloudFrontRequestEvent,
  _context: awsLambda.Context,
  callback: awsLambda.Callback<awsLambda.CloudFrontRequestResult>,
): void => {
  const request = event.Records[0].cf.request;

  let prefixPath;

  if (request.uri.match('.+/$')) {
    request.uri += 'index.html';
    callback(null, request);
  } else if ((prefixPath = request.uri.match('(.+)/index.html'))) {
    callback(null, generateRedirectResponse(prefixPath[1] + '/'));
  } else if (request.uri.match('/[^/.]+$')) {
    callback(null, generateRedirectResponse(request.uri + '/'));
  } else {
    callback(null, request);
  }
};

I started coding this lambda by wholesale copying standard redirects for cloudfront. It is arguably a better practice to simply import their application as a sub-stack, but I explicitly wanted to create a TypeScript lambda.

The first thing I will point out is that the @types/aws-lambda library is flat out fantastic for making lambdas in TypeScript. I simply searched through the various lambda handlers and found the appropriate signature. After that, it was incredibly simple to just put the pieces in place.

At this point we have a lambda and the code to deploy it.

Do Some Testing Link to heading

One of cdk’s major features is extensive support for testing your code. Drop the following into test/cloudfront-redirect.test.ts. If you’re feeling pedantic, you could start with the test and then add functionality on a resource by resource basis.

import { expect as expectCDK, haveResource } from '@aws-cdk/assert';
import cdk = require('@aws-cdk/core');
import CuT = require('../lib');

test('cloudfront-redirect basic functionality', () => {
  const app = new cdk.App();
  const stack = new cdk.Stack(app, 'TestStack');
  // WHEN
  new CuT.CloudfrontRedirect(stack, 'CodeUnderTest');
  // THEN
  expectCDK(stack).to(haveResource('AWS::IAM::Role'));
  expectCDK(stack).to(haveResource('AWS::Lambda::Function'));
  expectCDK(stack).to(haveResource('AWS::Lambda::Version'));
});

This is a pretty great way to be explicit about what you expect cdk to deploy. And, I have never seen a developer actually follow this pattern. What I see developers actually doing is writing the cdk and testing it by deploying it. And then grudgingly writing UTs after the fact. That’s what I did in this case. And… for that style of development, cdk has a pretty reasonable solution.

import cdkAssert = require('@aws-cdk/assert');
import cdk = require('@aws-cdk/core');
import CuT = require('../lib');

test('cloudfront-redirect basic functionality', () => {
  const app = new cdk.App();
  const stack = new cdk.Stack(app, 'TestStack');
  // WHEN
  new CuT.CloudfrontRedirect(stack, 'CodeUnderTest');
  // THEN
  cdkAssert.expect(cdkAssert.SynthUtils(stack)).toMatchSnapshot();
});

The first time you run this test, it will generate a snapshot of the CloudFormation template generated by cdk. This will land in the test/__snapshots__/ directory of your code. Commit this along with your test. Later, when you have to make a change to your code, you can diff the resulting output to confirm that your change didn’t accidentally remove something critical. This will not give you the primary benefit of TDD (an architectural focus on testable code), but it does give you a reasonable level of confidence around change analysis.

Add a Stack Link to heading

Finally, we’ll head back to cloudfront-redirect.ts for one last piece. We need a stack to actually deploy the construct we’ve made. Fortunately, it can be a very thin wrapper:

export class CloudfrontRedirectStack extends cdk.Stack {
  public readonly fn: lambda.Function;
  public readonly version: lambda.Version;

  constructor(scope: cdk.Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    const c = new CloudfrontRedirect(this, 'RedirectLambdaApplication');
    this.fn = c.fn;
    this.version = c.version;
  }
}

Deploy the Lambda Stack Link to heading

Now that the library is ready to deploy, the next step is to deploy it. Pop over to bin/app.ts. Make it look something like the following.

#!/usr/bin/env node
import 'source-map-support/register';
import cdk = require('@aws-cdk/core');
import lib = require('../lib');

const commonProps = {
  env: {
    account: '263869919117', // ahammond's personal AWS account ID. Use your own.
    region: 'us-east-1', // Not sure what other regions will support all this.
  },
};

const app = new cdk.App();

const redirect = new lib.CloudfrontRedirectStack(app, 'RedirectLambda', {
  ...commonProps,
  stackName: 'RedirectLambda', // Be super explicit with cdk about the name of this stack.
});

You can see that we’ve declared an application and then added the lambda to that app. We will be reusing the commonProps for other stacks deployed by this application. You could skip specifying the env and cdk will just deploy into whatever environment you happen to be working with. I’m starting to feel comfortable with that idea, but that’s the subject of another post. At this point, you should have a cdk application that builds without errors. Time to finally deploy something!

# See the all the things cdk knows about
npx cdk list

# Deploy the lambda stack
npx cdk deploy RedirectLambda

You’ll need to acknowledge the IAM changes around roles and permissions. I find this is a handy ways to troubleshoot my IAM grants. However, if you are deploying a bunch of stacks or otherwise automating things, you might consider adding --require-approval never to your command line. You can find the entire set of options in the manual.

Summary Link to heading

We covered a lot of bootstrapping and basics in this post. We got our first AWS resource deployed, which was fittingly, a lambda. Next post we will be covering a lot more ground, but moving much faster. We’ll be getting the site infrastructure deployed and testing it. And talking about fun topics like the composition of Constructs vs Stacks. Catch you next week.