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 |
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:
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',
},
. . .
});