How to Create CDK Constructs
The projen project has made everything below unnecessary Please stop reading this blog now and just go checkout projen for creating reusable CDK constructs with the JSII.
The AWS Cloud Development Kitoffers a powerful and flexible way to manage your AWS infrastructure. While the CDK ships with a lot of existing constructs to leverage, there are reasons you will need to create your own. Perhaps you want to abstract common infrastructure behind something that's easily reusable by your DevOps teams. Or maybe you need to enforce business standards. Either way, learning how to make a reusable construct provides huge recurring value over the life of your Infrastructure as Code.
I'm not going to cover much about what makes a good reusable construct (that's coming later). Instead, the goal is to show you the technical setup of how to get it done. First is creating a JSII module that is our construct, and the second is getting it built and published. In this case I'm going to use Github as my git repository and GitHub Actions for automatic publishing, leveraging Daniel Schroeder's excellent Docker container. For simplicity the construct will be a simple S3 Bucket with encryption enforced.
Finally, I'll talk a little bit about the CDK Construct Catalog, and how you can automatically publish to it.
Create a JSII-based construct
The JSII is the library that underpins the AWS CDK and makes it possible to write a CDK construct once in TypeScript/Javascript and then compile and distribute it for other languages like Python, Java, and C#.
Start by creating a new project, following the JSII docs. Take your time to read the Configuration section as there is a lot of useful information, some of which is absolutely required for proper JSII compilation and publishing.
Once done you should have the basic shell of a module. However, the JSII does not bootstrap any testing framework, so let's do that now.
$ npm i --save-dev @types/jest @types/node ts-jest jest
Let's also go ahead and fill out the scripts
section of the package.json by adding
the test/watch scripts:
{ "scripts": { "build": "jsii", "build:watch": "jsii -w", "package": "jsii-pacmak", "test": "tsc && jest", "watch": "tsc -w" } }
Also, create a jest.config.js
file in the base dir with the following contents:
module.exports = { "roots": [ "<rootDir>/" ], testMatch: ['**/*.test.ts'], "transform": { "^.+\\.tsx?$": "ts-jest" }, };
Now we can run some basic Jest tests around our construct with a simple command:
$ npm run test
But, we don't have any tests yet, so nothing happens.
Next we're going to write some unit tests and create a construct. I'm not going to go into details here, but you should at least see some code. In this case the construct is just going to wrap an S3 Bucket and ensure that it always has encryption enabled.
Don't forget to install some dependencies first, both for the unit testing and for the construct itself:
$ npm i -s @aws-cdk/core @aws-cdk/aws-s3 $ npm i --save-dev @aws-cdk/assert
Here's the test and code:
The test: lib/index.test.ts
import { SecureBucket } from "../lib/index"; import { App, Stack } from "@aws-cdk/core"; import '@aws-cdk/assert/jest'; import { BucketEncryption } from "@aws-cdk/aws-s3"; test('Has one encrypted Bucket', () => { const mockApp = new App(); const stack = new Stack(mockApp, 'testing-stack'); new SecureBucket(stack, 'testing', {}); expect(stack).toHaveResource("AWS::S3::Bucket", { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ { "ServerSideEncryptionByDefault": { "SSEAlgorithm": "aws:kms" } } ] } }); }); test('Does not allow for unencrypted buckets', () => { const mockApp = new App(); const stack = new Stack(mockApp, 'testing-stack'); new SecureBucket(stack, 'testing', { encryption: BucketEncryption.UNENCRYPTED }); expect(stack).toHaveResource("AWS::S3::Bucket", { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ { "ServerSideEncryptionByDefault": { "SSEAlgorithm": "aws:kms" } } ] } }); }); test('Allows override of default encryption', () => { const mockApp = new App(); const stack = new Stack(mockApp, 'testing-stack'); new SecureBucket(stack, 'testing', { encryption: BucketEncryption.S3_MANAGED }); expect(stack).toHaveResource("AWS::S3::Bucket", { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ { "ServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" } } ] } }); });
The code: lib/index.ts
import { Construct } from "@aws-cdk/core"; import { Bucket, BucketEncryption, BucketProps } from '@aws-cdk/aws-s3' export class SecureBucket extends Construct { constructor(scope: Construct, id: string, props?: BucketProps) { super(scope, id); let newProps: BucketProps = { ...props }; if (!props || props?.encryption === undefined || props?.encryption === BucketEncryption.UNENCRYPTED) { // @ts-ignore TS2540 newProps.encryption = BucketEncryption.KMS_MANAGED; } new Bucket(this, `${id}-bucket`, newProps); } }
Once that's setup you can run your unit tests:
$ npm run test
And we get some test results!
> secure-bucket@1.0.0 test /home/mbonig/projects/construct-blog/secure-bucket > jest PASS lib/index.test.ts ✓ Has one encrypted Bucket (59ms) ✓ Does not allow for unencrypted buckets (14ms) ✓ Allows override of default encryption (10ms) Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total Snapshots: 0 total Time: 1.175s Ran all test suites.
Now we can build and package the JSII module:
$ npm run build > secure-bucket@1.0.0 build /home/mbonig/projects/construct-blog/secure-bucket > jsii $ npm run package > secure-bucket@1.0.0 package /home/mbonig/projects/construct-blog/secure-bucket > jsii-pacmak
Now if you're like me, you're likely to get some errors during the first time you run build
and
package
because you need to have the dotnot cli, the *mvn8 cli and the
jdk installed, and you'll get a warning about twine so install that too.
Google
for your particular system's favorite package manager for details on getting them installed.
I run a debian-based distro so it's mostly:
$ pip3 install twine --user $ sudo apt install dotnet maven openjdk
Keep running your build and package until all errors are gone. The result should just be a new .jsii file in
the local directory and the dist/ directories getting filled out for our major package managers.
$ ls -al dist Permissions Size User Date Modified Git Name drwxr-xr-x - mbonig 11 Jan 9:42 -- dist/ drwxr-xr-x - mbonig 11 Jan 15:57 -- ├── dotnet/ drwxr-xr-x - mbonig 11 Jan 9:47 -- ├── java/ drwxr-xr-x - mbonig 11 Jan 15:57 -- ├── js/ drwxr-xr-x - mbonig 11 Jan 9:41 -- └── python/
Update: getting all of these dependencies on your machine is a pain. Instead, try the jsii/toolchain docker image.
$ docker run -it --rm -v `pwd`:/source jsii/superchain bash # cd /source # npm run build && npm run package
Huzzah! We now have a working JSII module that's unit tested and deliverable to the various package managers. Now we just have to deliver them! But first, go ahead and push your repository to Github and update references in your package.json accordingly.
By the way, all of this code is available here.
Github Actions
GitHub Actions is a very convienent and cheap automation pipeline. In our case, we're going to use Daniel Schroeder's Github Action to build and deploy our new JSII construct to NPM, NuGet, PyPi and Github (Maven).
Following the instructions on the linked page is pretty straight forward. You'll have to create accounts at all the places you want to publish and generate Access Tokens. Once you have them, create Secrets in your Github Repository. And you should be all set.
Go ahead now and create a new Tag/Release in your Github Repository and the Action will begin. Review its progress. You should see it produce log lines like:
Building source... ... Building packages... ... 📦 Publishing npm package... ... ✅ Done 📦 Publishing PyPI package... ... ✅ Done 📦 Publishing NuGet package... ... ✅ Done 📦 Publishing Maven package... ... ✅ Done
Of course, your mileage may vary if you decided not to publish to any of those Package Managers (but why would you do that?).
The Construct Catalog
In December 2019 the Construct Catalog was created to index and document all the fantastic CDK constructs that are being created by the community. If you create a JSII-based construct and publish it to NPM with a keyword of "cdk", like so:
{ "keywords": [ "cdk" ] }
Then your construct will automatically be picked up and cataloged by the Construct Catalog!