How to build, test, and deploy AWS CDK constructs with Projen

How to build, test, and deploy AWS CDK constructs with Projen

First of all, I have to give credit again to Pahud who also inspired me to create a dedicated development environment using Github Codespaces. A few days ago he also released a YouTube video about building AWS CDK Construct Library with Projen. The first time I heard about Projen was at CDK Day and I immediately saw it as a game changer. So in this post, I'm going to follow Pahud's demo and write about my experience using Projen to create AWS CDK constructs. All of the code can be found on my Github and feel free to reach out to me on Twitter.

Getting Started

For me, I created a new Github repository with my codespace-devtools template which includes all of the tools out-of-the-box. Otherwise, you can start by adding an empty directory and then open in your code editor:

mkdir cdk-noob && cd cdk-noob
code .

Next we need to initialize the project

npx projen new awscdk-construct

This will generate all a number of files but the most important is the .projenrc.js file. In fact, there are several files in the project that are read-only and refer you to the .projenrc file to make the necessary changes. By default, it comes with a lot of commented code to provide the available options.

You can delete the comments because we only really need to add cdk dependencies so that the .projenrc file looks like the following:

const { AwsCdkConstructLibrary } = require('projen');

const project = new AwsCdkConstructLibrary({
  authorAddress: "mypersonalemail@email.com",
  authorName: "Blake Green",
  cdkVersion: "1.73.0",
  name: "cdk-noob",
  repository: "https://github.com/bgreengo/cdk-noob",
  cdkDependencies: [
    '@aws-cdk/core',
    '@aws-cdk/aws-ec2',
    '@aws-cdk/aws-ecs',
    '@aws-cdk/aws-ecs-patterns',
  ]                                               

});

project.synth();

Next, in order to install the dependencies, run the following command:

npx projen

Now, you can take a look at the package.json file and notice that .projenrc added all of the dependencies automatically.

Creating the Construct

Inside the src directory, open the index.ts file. This is where we define the construct that we want to create. Delete the boilerplate code and import the dependencies:

import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as ecs from '@aws-cdk/aws-ecs';
import * as patterns from '@aws-cdk/aws-ecs-patterns';

Next, we can add the construct properties. For example, we can add an optional VPC property so if a VPC isn't defined, it will create a new VPC.

export interface NoobProps {
  readonly vpc?: ec2.IVpc;
}

Now, we can create the class to create the construct. This will actually contain the resources within the construct including the VPC, ECS cluster, Fargate task, and the Application Load Balanced Fargate service.

export class Noob extends cdk.Construct {
  readonly endpoint: string;
  constructor(scope: cdk.Construct, id: string, props: NoobProps = {}) {
    super(scope, id);

    const vpc = props.vpc ?? new ec2.Vpc(this, 'vpc', { natGateways: 1 });
    const cluster = new ecs.Cluster(this, 'cluster', { vpc });
    const task = new ecs.FargateTaskDefinition(this, 'task', {
      cpu: 256,
      memoryLimitMiB: 512,
    });
    task.addContainer('flask', {
      image: ecs.ContainerImage.fromRegistry('pahud/flask-docker-sample:latest'),
    }).addPortMappings({ containerPort: 80 });
    const svc = new patterns.ApplicationLoadBalancedFargateService(this, 'service', {
      cluster,
      taskDefinition: task,
    });
    this.endpoint = `http://${svc.loadBalancer.loadBalancerDnsName}`;
  }
}

And then we run yarn watch NOTE: make sure there's no errors being reported from yarn.

Integration Testing

In the same src directory as the index.ts, create another file called integ.default.ts and enter the following code:

import { Noob } from './index';
import * as cdk from '@aws-cdk/core';

export class IntegTesting {
  readonly stack: cdk.Stack[];
  constructor() {
    const app = new cdk.App();

    const env = {
      region: process.env.CDK_DEFAULT_REGION,
      account: process.env.CDK_DEFAULT_ACCOUNT,
    };

    const stack = new cdk.Stack(app, 'my-noob-stack', { env });

    new Noob(stack, 'mynoob');

    this.stack = [stack];
  }
}

new IntegTesting();

In another terminal, while yarn watch is still running, you can run the following cdk diff command to make sure everything is good so far:

npx cdk --app lib/integ.default.js diff

Next, you can deploy the stack to make sure everything works properly. After the deployment is complete, you should see the output to test the endpoint.

npx cdk --app lib/integ.default.js deploy

Next, we need to add a file called integ.snapshot.test.ts in the test directory of the project. Also, you can delete the auto generated file hello.test.ts so the test passes successfully. It should look something like this:

import '@aws-cdk/assert/jest';
import { SynthUtils } from '@aws-cdk/assert';
import { IntegTesting } from '../src/integ.default';

test('integ snapshot validation', () => {
  const integ = new IntegTesting();
  integ.stack.forEach(stack => {
    expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
  });
});

In the terminal, we can run yarn test and the test should pass. Now, in the __snapshots__ directory you can see the raw CloudFormation that was synthesized in the snapshot.

Now you can run yarn build which will generate the API.md document.

Don't forget to destroy the resources!!! While yarn watch is still running in another terminal session, run the following command to destroy the integration testing resources.

npx cdk --app lib/integ.default.js destroy

At this point, if you haven't already created a Github repository with my codespace-devtools template, you will want to create a new repo now.

Unit Testing

In a simple but effective way, we can easily add unit testing to the construct. Since we know that we will absolutely include an Application Load Balancer, we can create a unit test that checks the snapshot which should include that resource type.

Create a new file in the test directory called default.test.ts and include the following code:

import { App, Stack } from '@aws-cdk/core';
import { Noob } from '../src';
import '@aws-cdk/assert/jest';

test('create the default Noob construct', () => {
  //GIVEN
  const app = new App();
  const stack = new Stack(app, 'testing-stack');

  //WHEN
  new Noob(stack, 'Cluster');

  //THEN
  expect(stack).toHaveResource('AWS::ElasticLoadBalancingV2::LoadBalancer');
});

Run yarn test and make sure the test pass for both integration and unit tests.

Publish the Construct

We want to publish this construct to PyPI so we open the .projenrc.js file and add the following block under the cdkDependencies:

  python: {
    distName: 'cdk-noob',
    module: 'cdk_noob',
  }

Now run: npx projen This will automatically update the Github Actions workflow to include a release step to PyPI. You can see this by going to .github/workflows/release.yml and scroll towards the bottom. WOW! As you can also see, there's already an NPM step as well.

Next, we need to add the secrets to the repo for NPM and PyPI so that we can publish the construct. In the settings of the repo, go to secrets and add the following secrets from NPM and PyPI.

Secrets 2020-11-23 10-14-37.png

Back in the editor, we need to update the release branch by adding the following under the python code block: releaseBranches: ['main'] We can also exclude some common files that we don't want to add to the git repository by adding:

const common_exclude = ['cdk.out', 'cdk.context.json', 'images', 'yarn-error.log'];
project.npmignore.exclude(...common_exclude);
project.gitignore.exclude(...common_exclude);

Finally, we can run npx projen to update all the necessary files.

Now we are ready to commit all of the changes to the Github repository.

git add .
git commit -m "initial commit"
git branch -M main
yarn bump --release-as 0.1.0
git push --follow-tags origin main

Now you can go to your Github repository and on the Actions tab, you will see the Release job which will publish the construct to NPM and PyPI.

chore(release): 0.1.0 · bgreengo:cdk-noob@74b3f42 2020-11-23 13-57-24.png

After the build is finished, you can go to NPM and PyPI, search for the packages and see them on each platform.

cdk-noob - npm 2020-11-23 14-08-19.png

cdk-noob · PyPI 2020-11-23 14-07-12.png

Update the Construct

Let's say you want to add an update and you make a change to the README. Simply run:

git commit -am "update readme"
yarn release
git push --follow-tags origin main

This will allow you to add minor updates (i.e. v0.1.1) otherwise run yarn bump.