Building Shareable CDK Constructors using Projen

Building Shareable CDK Constructors using Projen

Rafael Zamana Kineippe

Rafael Zamana Kineippe

When you start building more and more projects using CDK, you start re-utilizing parts of code (or even entire constructors).

Most of the time, these constructors are simple parts that just a copy-and-paste solve the issue, but when you create a complex constructor that you want to share with the world, how can you properly apply the necessary rules AND release for the languages that CDK supports?

Thinking of this, the Projen Project came to my attention, with Projen you can build your constructor apply the basic rules for CDK, and release your library for the supported languages of CDK making use of JSII.

Projen adds the necessary backstage for compiling and releasing the libraries.

For example, I want to create a simple constructor to create an IAM User and create the access_key and secret_key for this user. The idea is to call a single constructor and have all the necessary information from it!

Less talk and more code!

Initiating the project

Let’s create our project sentia-user and use the project type for awscdk-construct from Projen.

$ mkdir sentia-user
$ cd sentia-user
$ git init
$ npx projen new awscdk-construct --author-name "Rafael Z. Kineippe" --description "AWS IAM User constructor with keys attached"

This generates the basic skeleton to start building our constructor! Let’s take a look at the file .projenrc.js:

const { AwsCdkConstructLibrary } = require('projen');
const project = new AwsCdkConstructLibrary({
  author: 'Rafael Z. Kineippe',
  authorAddress: 'foo@bar.com',
  authorName: 'Rafael Z. Kineippe',
  cdkVersion: '1.95.2',
  defaultReleaseBranch: 'main',
  description: 'AWS IAM User constructor with keys attached',
  name: 'sentia-user',
  repositoryUrl: 'https://github.com/foo.bar/sentia-user.git',

  // cdkDependencies: undefined,        /* Which AWS CDK modules (those that start with "@aws-cdk/") does this library require when consumed? */
  // cdkTestDependencies: undefined,    /* AWS CDK modules required for testing. */
  // deps: [],                          /* Runtime dependencies of this module. */
  // devDeps: [],                       /* Build dependencies for this module. */
  // packageName: undefined,            /* The "name" in package.json. */
  // projectType: ProjectType.UNKNOWN,  /* Which type of project this is (library/app). */
  // release: undefined,                /* Add release management to this project. */
});
project.synth();

Now, this default constructor doesn’t have any of the necessary information that we will need! We need to add our dependencies and the configuration for releasing the library!

TDD

We all love some Test Driven Development correct? So, this is the first configuration we will add, enabling the @aws-cdk/assert library in the project. To do this, add the parameter:

const project = new AwsCdkConstructLibrary({
  . . .
  cdkAssert: true,
  . . .
});

CDK Dependencies

When building constructors, you want to use the already existent modules from AWS CDK; this will help develop our constructor! We will need the core and the aws-iam modules from the AWS CDK for this example. Let’s add it:

const project = new AwsCdkConstructLibrary({
  . . .
  cdkDependencies: [
    '@aws-cdk/aws-iam',
    '@aws-cdk/core'
  ],
  . . .
});

Publish the library

For the sake of this article, we are using the PyPI and NPM repositories (the default option) to publish the library, for this we need the following configuration:

const project = new AwsCdkConstructLibrary({
  . . .
  release: true,
  publishToPypi: {
    distName: 'sentia-user',
    module: 'sentia_user'
  },
  . . .
});

It will create the necessary GitHub Action to publish the library.

Github Secrets

You need to configure the following Secrets in the repository on GitHub:

Secret Usage
TWINE_USERNAME The username used for PyPI publish
TWINE_PASSWORD The password used for PyPI publish
NPM_TOKEN The token used for NPM publish

Github Secrets

Other configurations

You can check the configuration parameters in Projen awscdk-construct Section.

Projen has many configurations available, and you can check all of them in the Projen Documentation!

Re-configure the project

After adding all the necessary configurations, let’s run npx projen to update the project!

$ npx projen

We can start building our library now!

Clean UP

The default project comes with a small constructor and the test for it. Let’s remove this so we can start working on our code!

$ rm src/index.ts test/hello.test.ts

We don’t want anything on our way!

Tests FIRST

When I first created a project, I thought, “How can I test it?”, we are not deploying. We are building CloudFormation templates! So, the answer to it, we are testing the CloudFormation template in the best way possible!

Resources Creation

Our project needs to create exactly 1 Resource of the type “AWS::IAM::User” and precisely 1 of the type “AWS::IAM::AccessKey” for each call.

For this, we will use the @aws-cdk/assert library, more specifically the expect, haveResource, and countResources methods!

Let’s start!

Create the file sentia_user.test.ts under the test folder, with the follow content:

import { expect, haveResource, countResources } from '@aws-cdk/assert';
import { Stack } from '@aws-cdk/core';
import { SentiaUser } from '../src';

describe('User', () => {
  it('creates the user', () => {
    const stack = new Stack();
    new SentiaUser(stack, 'User');
    expect(stack).to(haveResource('AWS::IAM::User'));
    expect(stack).to(countResources('AWS::IAM::User', 1));
    expect(stack).to(haveResource('AWS::IAM::AccessKey'));
    expect(stack).to(countResources('AWS::IAM::AccessKey', 1));
  });
});

A little bit redundant, I know! We can achieve the same with only countResources and not using the haveResource at all!

The haveResource accepts an object as second parameter, where we can make sure that the property we are sending to our library is being used! Like this:

import { expect, haveResource, countResources } from '@aws-cdk/assert';
import { Stack } from '@aws-cdk/core';
import { SentiaUser } from '../src';

describe('User', () => {
  it('creates the user', () => {
    const stack = new Stack();
    new SentiaUser(stack, 'User', {
      userName: 'rzamana',
    });
    expect(stack).to(haveResource('AWS::IAM::User', {
      userName: 'rzamana',
    }));
  });
});

Fail first!

Let’s see how this test goes.

$ npx projen test
🤖 test | rm -fr lib/
🤖 test » test:compile | tsc --noEmit --project tsconfig.jest.json
test/sentia_user.test.ts:3:23 - error TS2307: Cannot find module '../src' or its corresponding type declarations.

3 import { SentiaUser } from '../src';
                             ~~~~~~~~

Found 1 error.

🤖 Task "test » test:compile" failed when executing "tsc --noEmit --project tsconfig.jest.json" (cwd: /Users/rafaelzamanakineippe/Public/sentia-user)

Well! We expected this result! We don’t have our lib yet! Let’s solve this.

Creating the Constructor

Our constructor will reside in the src folder. Since this is a small constructor, I will place everything inside the index.ts file, but if your constructor is more complex, think about dividing the files following the S.O.L.I.D. approach!

Create the index.ts file inside the src folder, and we will start importing the libraries our constructor will use:

import * as iam from '@aws-cdk/aws-iam';
import * as core from '@aws-cdk/core';

We will use the AWS IAM library to create the user and the keys and the core library to create the constructor and access the Secret Key attribute!

The constructor will extend the functionality from the @aws-cdk/aws-iam.User to create our own, the constructor will look like this:

export class SentiaUser extends iam.User {
  constructor(scope: core.Construct, id: string, props?: iam.UserProps) {
    super(scope, id, props);

    let access_keys = new iam.CfnAccessKey(scope, `${id}AccessKeys`, {
      userName: this.userName,
    });
    let secret_key = core.Fn.getAtt(access_keys.logicalId, 'SecretAccessKey');
  }
}

Just add some properties to the constructor to have access to it outside of the constructor!

The full constructor looks like this:

import * as iam from '@aws-cdk/aws-iam';
import * as core from '@aws-cdk/core';

export class SentiaUser extends iam.User {
  /**
   * An attribute that represents the user access_key.
   *
   * @attribute true
   */
  readonly accessKey: string;
  /**
    * An attribute that represents the user secret_key.
    *
    * @attribute true
    */
  readonly secretKey: string;

  constructor(scope: core.Construct, id: string, props?: iam.UserProps) {
    super(scope, id, props);

    let access_key = new iam.CfnAccessKey(scope, `${id}AccessKeys`, {
      userName: this.userName,
    });
    let secret_key = core.Fn.getAtt(access_key.logicalId, 'SecretAccessKey');

    this.accessKey = access_key.ref;
    this.secretKey = secret_key.toString();
  }
}

Let’s test again!

Running our test now it should have the green light:

$ npx projen test
🤖 test | rm -fr lib/
🤖 test » test:compile | tsc --noEmit --project tsconfig.jest.json
🤖 test | jest --passWithNoTests --all --updateSnapshot
 PASS  test/sentia_user.test.ts
  User
    ✓ creates the user (51 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |
 index.ts |     100 |      100 |     100 |     100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.757 s, estimated 2 s
Ran all test suites.
🤖 test » eslint | eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js

Now, our library is ready for action! Let’s configure the projen to publish this library into NPM (default) and PyPi.

Publishing the library

We had configured the project to deploy the library to NPM and PyPi on .projenrc.js.

Conventional Commits

Projen makes use of conventional commits, more info at the Conventional Commits WebSite.

We will create a new commit with the message “feat: First release”

$ git add -A
$ git commit -m "feat: First release"

GitHub Actions

When we push the code to the main branch on Github, it will trigger the Github Actions to build and release the library:

GitHub Actions Build
GitHub Actions Release

After that, you can check the library on the package repositories!

Finish

And that is it! Your new library is available for the community, and you can start making use of it on your projects simply by importing the packages you need!

Here I just added the NPM and PyPi repository, but Projen supports much more languages and repositories. Take some time to look into the documentation, and you use the comments below if you have any doubts.

Extras

When using Projen, you can keep your library up-to-date with the latest release from the aws-cdk and their dependencies. Projen has configurations to enable dependabot and mergify, two great tools to keep your project up-to-date, enable the necessary configurations:

const project = new AwsCdkConstructLibrary({
  . . .
  autoApproveProjenUpgrades: true,
  autoApproveUpgrades: true,
  dependabot: true,
  autoApproveOptions: {
    secret: 'MY_GITHUB_TOKEN',
  },
  . . .
});