Creating a CDK Construct Library with projen – A runbook of sorts.

Introduction

AWS CDK allows you to create a Construct Library and publish, so it can be consumed by other projects. Writing the constructs in Typescript with JSII, allows the publication of packages for use in Python, Javascript, Go and Java with minimal effort via the JSII transpiler. ( it magic i dont’ really understand, but its useful! )

Using projen makes the development of Construct Library nearly lazy. projen is a aws project, but it’s not entirely obvious how to get started.

The content of this runbook has been tested with the following versions, but should work with more recent versions

  • projen: v0.56.33
  • AWS CDK: v2.25.0

What is projen?

projen is a tool for defining and managing increasingly complex project configurations in code.
https://github.com/projen/projen

With projen, you no longer need to manage files such as package.json by yourself.

projen does not only generate various files during project creation and continuously updates and maintains these settings.

You can quickly start a new project using the pre-defined project types.

Commands:
  projen new awscdk-app-java   AWS CDK app in Java.
  projen new awscdk-app-py     AWS CDK app in Python.
  projen new awscdk-app-ts     AWS CDK app in TypeScript.
  projen new awscdk-construct  AWS CDK construct library project.
  projen new cdk8s-app-ts      CDK8s app in TypeScript.
  projen new cdk8s-construct   CDK8s construct library project.
  projen new cdktf-construct   CDKTF construct library project.
  projen new java              Java project.
  projen new jsii              Multi-language jsii library project.
  projen new nextjs            Next.js project without TypeScript.
  projen new nextjs-ts         Next.js project with TypeScript.
  projen new node              Node.js project.
  projen new project           Base project.
  projen new python            Python project.
  projen new react             React project without TypeScript.
  projen new react-ts          React project with TypeScript.
  projen new typescript        TypeScript project.
  projen new typescript-app    TypeScript app.


The awscdk-construct creates an environment for building aws cdk Contructs using jsii.
jsii allows you to generate libraries from TypeScript code to work in Python, Java, and .NET.

Create project

Create a Construct Library project with projen new awscdk-construct

$ mkdir cdk-sample-lib && cd cdk-sample-lib

$ npx projen new awscdk-construct
👾 Project definition file was created at /home/ec2-user/environment/cdk-sample-lib/.projenrc.js
yarn install v1.22.18
info No lockfile found.
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
Done in 38.61s.
yarn install v1.22.18
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
Done in 7.21s.

> cdk-sample-lib@0.0.0 eslint
> npx projen eslint

Initialized empty Git repository in /home/ec2-user/environment/cdk-sample-lib/.git/
[main (root-commit) 924b25c] chore: project created with projen
 21 files changed, 7704 insertions(+)
 create mode 100644 .eslintrc.json
 create mode 100644 .gitattributes
 create mode 100644 .github/pull_request_template.md
 create mode 100644 .github/workflows/build.yml
 create mode 100644 .github/workflows/pull-request-lint.yml
 create mode 100644 .github/workflows/release.yml
 create mode 100644 .github/workflows/upgrade-main.yml
 create mode 100644 .gitignore
 create mode 100644 .mergify.yml
 create mode 100644 .npmignore
 create mode 100644 .projen/deps.json
 create mode 100644 .projen/files.json
 create mode 100644 .projen/tasks.json
 create mode 100644 .projenrc.js
 create mode 100644 LICENSE
 create mode 100644 README.md
 create mode 100644 package.json
 create mode 100644 src/index.ts
 create mode 100644 test/hello.test.ts
 create mode 100644 tsconfig.dev.json
 create mode 100644 yarn.lock

Under the project directory, .projenrc.js has been created.

const { awscdk } = require('projen');
const project = new awscdk.AwsCdkConstructLibrary({
  author: 'user',
  authorAddress: 'user@example.com',
  cdkVersion: '2.1.0',
  defaultReleaseBranch: 'main',
  name: 'cdk-sample-lib',
  repositoryUrl: 'https://github.com/user/cdk-sample-lib.git',

  // deps: [],                /* Runtime dependencies of this module. */
  // description: undefined,  /* The description is just a string that helps people understand the purpose of the package. */
  // devDeps: [],             /* Build dependencies for this module. */
  // packageName: undefined,  /* The "name" in package.json. */
});
project.synth();

You can add dependencies on AWS CDKs and other modules to be used.

 deps: [
    '@aws-cdk/aws-apigatewayv2-alpha',
    '@aws-cdk/aws-apigatewayv2-integrations-alpha',
    'other-useful-lib' 
  ]

Add the target language if you want to cross-compile to languages other than TypeScript with jsii. We will by default publish to PyPi and and NPM

  publishToPypi: {
    distName: 'cdk-sample-lib',
    module: 'cdk_sample_lib',
  },

See the API reference for other options that can be specified.
As an example, the modified .projenrc.js file looks like this

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

const cdkVersion = '2.25.0';

const project = new awscdk.AwsCdkConstructLibrary({
  author: 'mrpackethead',
  authorAddress: 'mrpackethead@users.noreply.github.com',
  cdkVersion,
  defaultReleaseBranch: 'main',
  name: 'cdk-sample-lib',
  repositoryUrl: 'https://github.com/mrpackethead/cdk-sample-lib.git',
  description: 'Sample AWS CDK Construct Library by projen',
  keywords: ['sample'],
  license: 'Apache-2.0',
  deps: [
    `@aws-cdk/aws-apigatewayv2-alpha@${cdkVersion}-alpha.0`,
    `@aws-cdk/aws-apigatewayv2-integrations-alpha@${cdkVersion}-alpha.0`
  ],
  publishToPypi: {
    distName: 'cdk-sample-lib',
    module: 'cdk_sample_lib',
  },
  stability: 'experimental',
});
project.synth();

Once you have edited .projenrc.js, run the projen command to reflect the changes.

$ npx projen
👾 default | node .projenrc.js
yarn install v1.22.18
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 14.87s.

You will see that projen automatically generates the package.json, the .gitignore, .npmignore, eslint, jsii configuration, license files, etc., and the creation and installation of the package.json.

You no longer have to copy from an existing project every time you create a new project. ( and NOR Should you )

You need to modify the .projenrc.js file and re-run the projen command whenever you edit these files.
NOTE: If you edit them manually, the build will fail.



Development Example.

Let’s build a simple example of calling the ‘Kia ora te Ao’ Lambda from the API Gateway (HTTP API).
( note that the HTTP API L2 Constructs has an Experimental status as of May 2022. )

The following directory has already been created by projen.

.
├── lib/ 
├── src/
├── test/

The code for the Lambda functions can also be inserted inline into the CDK code, but this example creates index.js in the functions directory.

  • functions/index.js
exports.handler = async (event) => {
    const response = {
        statusCode: 200,
        body: JSON.stringify('Kia ora te Ao'),
    };
    return response;
};

Create the following two files in the src directory.

  • src/index.ts

import { HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha';
import { HttpLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha';
import * as cdk from 'aws-cdk-lib';
import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class CdkSampleLib extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const handler = new Function(this, 'kiaOraTeAo', {
      handler: 'index.handler',
      code: Code.fromAsset('functions'),
      runtime: Runtime.NODEJS_16_X,
    });

    const api = new HttpApi(this, 'API', {
      defaultIntegration: new HttpLambdaIntegration('LambdaIntegration', handler),
    });

    new cdk.CfnOutput(this, 'ApiURL', { value: api.url! });
  }
}

src/integ.default.ts

import * as cdk from 'aws-cdk-lib';
import { CdkSampleLib } from './index';

const app = new cdk.App();
const stack = new cdk.Stack(app, 'MyStack');

new CdkSampleLib(stack, 'Cdk-Sample-Lib');
Create the following file in the test directory.

test/cdk-sample-lib.test.ts
import { App, Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { CdkSampleLib } from '../src/index';

const mockApp = new App();
const stack = new Stack(mockApp);
new CdkSampleLib(stack, 'testing-stack');
const template = Template.fromStack(stack);

test('Lambda functions should be configured with properties and execution roles', () => {
  template.hasResourceProperties('AWS::Lambda::Function', {
    Runtime: 'nodejs16.x',
  });

  template.hasResourceProperties('AWS::IAM::Role', {
    AssumeRolePolicyDocument: {
      Statement: [
        {
          Action: 'sts:AssumeRole',
          Effect: 'Allow',
          Principal: {
            Service: 'lambda.amazonaws.com',
          },
        },
      ],
      Version: '2012-10-17',
    },
  });
});

test('HTTP API should be created', () => {
  template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
    ProtocolType: 'HTTP',
  });
});

test('Lambda Integration should be created', () => {
  template.hasResourceProperties('AWS::ApiGatewayV2::Integration', {
    IntegrationType: 'AWS_PROXY',
  });
});

Unit Test

Various scripts are predefined in the package.json generated from projen.

Run the test with yarn test (npx projen test).
yarn build (npx projen build) also runs test.

Build

Run yarn build and compile TypeScript to the jsii module.

jsii-docgen generates API documentation from comments in the code! ( Boom! Seriously we’re autodocing here peps. No word docs! )

In addition, jsii-pacmak creates language-specific public packages in the dist directory.

 yarn build
👾 build » default | node .projenrc.js
yarn install v1.22.18
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
Done in 55.48s.
👾 build » compile | jsii --silence-warnings=reserved-word
👾 build » post-compile » docgen | jsii-docgen -o API.md
👾 build » test | jest --passWithNoTests --all --updateSnapshot
 PASS  test/cdk-sample-lib.test.ts (9.805 s)
  ✓ Lambda functions should be configured with properties and execution roles (3 ms)
  ✓ HTTP API should be created (1 ms)
  ✓ Lambda Integration should be created (1 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:       3 passed, 3 total
Snapshots:   0 total
Time:        10.121 s
Ran all test suites.
👾 build » test » eslint | eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js
👾 build » package | if [ ! -z ${CI} ]; then mkdir -p dist && rsync -a . dist --exclude .git --exclude node_modules; else npx projen package-all; fi
👾 package-all » package:js | jsii-pacmak -v --target js
[jsii-pacmak] [INFO] Found 1 modules to package
[jsii-pacmak] [INFO] Packaging NPM bundles
[jsii-pacmak] [INFO] Loading jsii assemblies and translations
[jsii-pacmak] [INFO] Packaging 'js' for cdk-sample-lib
[jsii-pacmak] [INFO] js finished
[jsii-pacmak] [INFO] Packaged. load jsii (2.1s) | npm pack (0.4s) | js (0.0s) | cleanup (0.0s)
👾 package-all » package:python | jsii-pacmak -v --target python
[jsii-pacmak] [INFO] Found 1 modules to package
[jsii-pacmak] [INFO] Packaging NPM bundles
[jsii-pacmak] [INFO] Loading jsii assemblies and translations
[jsii-pacmak] [INFO] Packaging 'python' for cdk-sample-lib
[jsii-pacmak] [INFO] python finished
[jsii-pacmak] [INFO] Packaged. python (15.9s) | load jsii (1.9s) | npm pack (0.4s) | cleanup (0.0s)

Once the build is successful, let’s try deploying locally.

cdk deploy --app='./lib/integ.default.js'

✨  Synthesis time: 1.27s

current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-lookup-role-123456789012-ap-southeast-2', but are for the right account. Proceeding anyway.
(To get rid of this warning, please upgrade to bootstrap version >= 8)
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-ap-southeast-2', but are for the right account. Proceeding anyway.
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬────────────────────────────────────────────────────────────────────┬────────┬───────────────────────┬──────────────────────────────────┬─────────────────────────────────────────────────────────────────────┐
│   │ Resource                                                           │ Effect │ Action                │ Principal                        │ Condition                                                           │
├───┼────────────────────────────────────────────────────────────────────┼────────┼───────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ + │ ${Cdk-Sample-Lib/kiaOraTeAo.Arn}                                   │ Allow  │ lambda:InvokeFunction │ Service:apigateway.amazonaws.com │ "ArnLike": {                                                        │
│   │                                                                    │        │                       │                                  │   "AWS:SourceArn": "arn:${AWS::Partition}:execute-api:${AWS::Region │
│   │                                                                    │        │                       │                                  │ }:${AWS::AccountId}:${CdkSampleLibAPI6FD5D6E6}/*/*"                 │
│   │                                                                    │        │                       │                                  │ }                                                                   │
├───┼────────────────────────────────────────────────────────────────────┼────────┼───────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ + │ ${Cdk-Sample-Lib/kiaOraTeAo/ServiceRole.Arn}                       │ Allow  │ sts:AssumeRole        │ Service:lambda.amazonaws.com     │                                                                     │
└───┴────────────────────────────────────────────────────────────────────┴────────┴───────────────────────┴──────────────────────────────────┴─────────────────────────────────────────────────────────────────────┘
IAM Policy Changes
┌───┬──────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                                 │ Managed Policy ARN                                                             │
├───┼──────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${Cdk-Sample-Lib/HelloWorld/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴──────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
MyStack: deploying...
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-ap-southeast-2', but are for the right account. Proceeding anyway.
[0%] start: Publishing 91983f53eff8228528504631ea088fb748d797c41b9117601e9b1ed390057a51:current_account-current_region
[0%] start: Publishing 9c1606accb41e40678fb0f9503bf3fbfe23c7c6f77c1550c8421da0b84444171:current_account-current_region
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-file-publishing-role-123456789012-ap-southeast-2', but are for the right account. Proceeding anyway.
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-file-publishing-role-123456789012-ap-southeast-2', but are for the right account. Proceeding anyway.
[50%] success: Published 91983f53eff8228528504631ea088fb748d797c41b9117601e9b1ed390057a51:current_account-current_region
[100%] success: Published 9c1606accb41e40678fb0f9503bf3fbfe23c7c6f77c1550c8421da0b84444171:current_account-current_region
MyStack: creating CloudFormation changeset...

 ✅  MyStack

✨  Deployment time: 68.01s

Outputs:
MyStack.CdkSampleLibApiURL32C6192A = https://4p9zte6ny8.execute-api.ap-southeast-2.amazonaws.com/
Stack ARN:
arn:aws:cloudformation:ap-southeast-2:123456789012:stack/MyStack/a41998b0-de9f-11ec-88d1-0afcbfc50359

✨  Total time: 69.28s

You can check the response of the Lambda function from the output API URL. ( note your url, will be different )

$ curl https://4p9zte6ny8.execute-api.ap-southeast-2.amazonaws.com/
"Kia ora te Ao"

To remove it, run the cdk destory.

$ cdk destroy --app='./lib/integ.default.js'   
Are you sure you want to delete: MyStack (y/n)? y
MyStack: destroying...
current credentials could not be used to assume 'arn:aws:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-ap-southeast-2', but are for the right account. Proceeding anyway.

 ✅  MyStack: destroyed

Release

Commit the changes and push the code to GitHub.

$ git add . 
$ git commit -m "feat: initial release"

projen automatically performs semantic versioning based on Conventional Commits.


For example

  • fix: bump PATCH version (v0.0.1)
  • feat: bump MINOR version (v0.1.0)


MAJOR version must be explicitly bumped by adding majorVersion: x to .projenrc.js to protect users from critical changes.

The Github Actions workflow definition is also generated when the projen command is executed, making it easy to automate the release to the package repository.

  • Build workflow (.github/workflows/build.yaml)
    It runs when a pull request is created.
    Builds the library and checks for tampering (i.e., manual modification).
  • Release workflow (.github/workflows/release.yaml):
    git push to the release branch triggers it.
    Builds the library and checks for tampering (i.e., manual modification).
    Bumping of Release Version by Conventional Commits.
    Create changelog.
    Automated releases to various package repositories such as GitHub Releases, npm, and PyPI.

publib is used for releases to the repository.

For Workflow to work correctly, Personal Access Token used by projen and API_KEY or Token corresponding to the repository to which it is published must be registered in Actions secrets.

  • PAT for projen: PROJEN_GITHUB_TOKEN (Scopes are repo, workflows, and packages.
  • npm: NPM_TOKEN
  • .NET: NUGET_API_KEY
  • Java: MAVEN_GPG_PRIVATE_KEY, MAVEN_GPG_PRIVATE_KEY_PASSPHRASE, MAVEN_PASSWORD, MAVEN_USERNAME, MAVEN_STAGING_PROFILE_ID
  • Python: TWINE_USERNAME, TWINE_PASSWORD

Publishing to Construct Hub. ( Public Sharing )

Construct Hub is a registry site for discovering and sharing custom Construct Libraries from the community, AWS, and AWS partners.

GA last December on the same day as AWS CDK v2. Currently, more than 1000 Construct Libraries are listed.

Construct Hub

The following conditions must be met to publish your library on Construct Hub.

  • JSII-compatible
  • Open source license
    • Apache, BSD, EPL, MPL-2.0, ISC, and CDDL or MIT
  • Published to the npm Registry using the CDK Keywords.
    • cdk, awscdk, aws-cdk, cdk8s, or cdktf

If a library meets these requirements, it will be automatically detected and published to Construct Hub in about 30 minutes.

Libraries created and published using projen will meet these requirements as long as they are under the applicable open source license. Therefore, they will be listed on Construct Hub without any special handling.

Useful Material:

The following video published by @pahud, an AWS Developer Advocate, is very helpful.

EP50 – (ENGLISH) Building AWS CDK Construct Library with Projen


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.