Exploring AWS CDK - Step Functions
Tags: aws, typescript, devops, serverless
Published: 2019-12-30 14:53:48

I've got a little downtime over the holidays and what better way to unwind than to learn something? I'm learning about a couple of technologies that have been on my mind for several weeks.

## tl;dr

If you aren't interested in the journey and just want to see the code, go ahead and [check out my repo](https://github.com/elthrasher/cdk-step-functions-example).

**Code samples have been updated to CDK v2.**

## Table of Contents
* [AWS CDK](#aws-cdk)
* [Step Functions](#step-functions)
* [Resources](#resources)
* [Toolchain](#toolchain)
* [Initialize CDK](#initialize-cdk)
* [Import Style](#import-style)
* [TypeScript Build](#typescript-build)
* [Linting](#linting)
* [CDK Anatomy](#cdk-anatomy)
* [Lambda Functions](#lambda-functions)
* [More Tests](#more-tests)
* [CDK Lambda](#cdk-lambda)
* [CDK Step Functions](#cdk-step-functions)
* [IAM Roles](#iam-roles)
* [Testing CDK](#testing-cdk)
* [Deploy and Run](#deploy-and-run)
* [SAM](#sam)
* [Next Steps](#next-steps)

## AWS CDK <a name="aws-cdk"></a>

[Amazon Cloud Developer Kit](https://aws.amazon.com/cdk/) is a wrapper for CloudFormation, the AWS infrastructure as code solution. Instead of writing CloudFormation templates in yaml or json, you can write them in TypeScript (or C# or Java or Python). CDK provides higher-order abstractions that reduce extremely verbose CloudFormation templates into a few lines of TypeScript.

## Step Functions <a name="step-functions"></a>

[Step Functions](https://aws.amazon.com/step-functions/) allow you to orchestrate serverless components into a [Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) or workflow.

If any of those concepts aren't clear yet, stay with me. I intend to explore them with code samples and examples.

## Resources <a name="resources"></a>

[AWS CDK Examples](https://github.com/aws-samples/aws-cdk-examples) is a pretty good resource for learning CDK. It even has a [step functions example](https://github.com/aws-samples/aws-cdk-examples/tree/master/typescript/stepfunctions-job-poller), but while I was able to build the project, I wasn't able to figure out how to actually make it do anything and I wanted something that integrates with other serverless components besides.

Continuing to search, I came across [this article](https://aws.amazon.com/getting-started/tutorials/create-a-serverless-workflow-step-functions-lambda/). This one is a pretty good tutorial that combines five lambda functions into a flow using AWS Console (so not CDK). The tutorial is a little outdated. Not all of the pictures match the present state of the AWS Console. However, it's still a pretty good tutorial and in fact highly recommended for anyone who wants to get a little more familiar with the AWS Console.

## Toolchain <a name="toolchain"></a>

I wanted to see if I could convert the lambdas and state machine in the example above to CDK. To get started I need a couple of things:

```bash
$ node --version
v12.14.0
$ cdk --version
1.19.0 (build 5597bbe)
```

It might be a good idea to have aws-cli and we'll give sam-cli a try a bit later, but this is good enough for now. If you need to install [nodejs](https://nodejs.org/en/) or [cdk](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html#getting_started_install), it shouldn't be hard to figure out.

## Initialize CDK <a name="initialize-cdk"></a>

Following the guide, I'll bootstrap my project.

```
$ mkdir cdk-step-functions-example && cd cdk-step-functions-example
$ cdk init --language typescript
```

I'm rewarded with a skeleton project and some npm modules installed.

## Import Style <a name="import-style"></a>

The generated CDK project has a few conventions that I'd like to change. It's really just a matter of taste, but I don't like the `import foo = require('foo');` syntax. I find it clutters my code and is less declarative than `import { foo, bar } from 'foo';`. So I'm going to take the default stack declaration

```typescript
import cdk = require('@aws-cdk/core');

export class CdkStepFunctionsExampleStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here
  }
}
```

and rewrite it to

```typescript
import { Construct, Stack, StackProps } from '@aws-cdk/core';

export class CdkStepFunctionsExampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here
  }
}
```

I gave `bin/cdk-step-functions-example.ts` the same treatment.

```typescript
#!/usr/bin/env node
import "source-map-support/register";

import { App } from "@aws-cdk/core";

import { CdkStepFunctionsExampleStack } from "../src/cdk-step-functions-example-stack";

const app = new App();
new CdkStepFunctionsExampleStack(app, "CdkStepFunctionsExampleStack");

```

I don't guarantee that I'm right and AWS is wrong or that even anybody is right or wrong, but I'm going with the latter syntax for now. YMMV.

## TypeScript Build <a name="typescript-build"></a>

I'm also going to update my `tsconfig.json` file, the file that tells TypeScript what decisions to make when it transpiles my TypeScript code to JavaScript. The choices CDK makes for me are mostly pretty good, but one thing I didn't like is that it transpiled my code inline instead of sending it to an output directory, which cluttered my source. I also enabled `esModuleInterop` and `resolveJsonModule` for reasons we'll arrive at later. Here's what I've got now:

```json
{
  "compilerOptions": {
    "alwaysStrict": true,
    "declaration": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "inlineSourceMap": true,
    "inlineSources": true,
    "lib": ["es2018"],
    "module": "commonjs",
    "noFallthroughCasesInSwitch": false,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "outDir": "build",
    "resolveJsonModule": true,
    "strict": true,
    "strictNullChecks": true,
    "strictPropertyInitialization": false,
    "target": "ES2018",
    "typeRoots": ["./node_modules/@types"]
  },
  "exclude": ["build", "cdk.out"]
}
```

I didn't love the idea of my source code being in `lib` so I renamed that to `src`. Now my source code is in `src` and I build it to `build`. To support this change, I need to modify my `cdk.json` file to point to the new location of my output files: `"app": "node build/bin/cdk-step-functions-example.js"`.

## Linting <a name="linting"></a>

Finally, something I feel that's missing from the CDK generated project is linting! Since tslint is [deprecated](https://medium.com/palantir/tslint-in-2019-1a144c2317a9), I will use eslint and prettier. If I explained all my choices, this would be an article on linting, not CDK and Step Functions, so here are the codes. Change them as you please!

```bash
$ npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier prettier
```

I'm adding an `.eslintignore` file

```
*.d.ts

build
cdk.out

```

and an `.eslintrc.js` file

```javascript
module.exports = {
  extends: [
      'plugin:@typescript-eslint/recommended',
      'prettier/@typescript-eslint',
      'plugin:prettier/recommended',
  ],
  parser: '@typescript-eslint/parser',
  parserOptions: {
      ecmaVersion: 2019,
      project: './tsconfig.json',
      sourceType: 'module',
  },
  rules: {
    "@typescript-eslint/interface-name-prefix": ["error", { "prefixWithI": "always" }],
    "@typescript-eslint/no-floating-promises": ["error"],
    "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
  }
};
```

and finally a `.prettierrc.js` file.

```javascript
module.exports =  {
  semi:  true,
  trailingComma:  'all',
  singleQuote:  true,
  printWidth:  120,
  tabWidth:  2,
};

```

I added a new npm script: `"lint": "eslint --ext .ts ."`.

Now I can finally lint my code and fix existing issues.

```bash
$ npm run lint -- --fix
```

## CDK Anatomy <a name="cdk-anatomy"></a>

Let's get back to CDK. I could talk about Constructs and Stacks, but AWS does a pretty good job of that [right here](https://docs.aws.amazon.com/cdk/latest/guide/core_concepts.html). My CDK stack is just a class constructor that extends the base `Stack` class. As I build out my stack, I'll add more code to the constructor method to represent lambdas and steps. But before I even add any more code, I can run `cdk synth` to generate an empty CloudFormation template. I can also run the simple test included in the generated project with `npm t`.

## Lambda Functions <a name="lambda-functions"></a>

The original [tutorial](https://aws.amazon.com/getting-started/tutorials/create-a-serverless-workflow-step-functions-lambda/) has us create the state machine first, then start adding lambdas and roles. I'm going to start with the lambdas.

The tutorial I'm working from has us pasting the handler functions into the AWS Console. I want to add my lambdas into my project so they can be bundled up with CDK and deployed along with the state machine. The tutorial writes lambdas for NodeJS 4 (which is deprecated) using callbacks and ES5 syntax. Since I'm already using TypeScript, I will write my lambdas in TypeScript and transpile them along with my CDK code. I'll also use [SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) for local execution and write some tests as well.

To start I'm going to create a new directory under `src` to house my lambdas and create a new ts file there to work on my lambda function.

```bash
$ mkdir src/lambda
$ touch src/lambda open-case.ts
```

The old ES5 code from the tutorial looked like this:

```javascript
exports.handler = (event, context, callback) => {
    // Create a support case using the input as the case ID, then return a confirmation message   
   var myCaseID = event.inputCaseID;
   var myMessage = "Case " + myCaseID + ": opened...";   
   var result = {Case: myCaseID, Message: myMessage};
   callback(null, result);    
};
```

Let's start with that. Because TypeScript is a superset of JavaScript, this is valid TypeScript (depending on your linting and compiler rules), but it can improved by using an async function instead of a callback, `const` and `let` in lieu of `var` and by removing unused arguments.

```typescript
export const handler = async event => {
  // Create a support case using the input as the case ID, then return a confirmation message
  const myCaseID = event.inputCaseID;
  const myMessage = 'Case ' + myCaseID + ': opened...';
  return { Case: myCaseID, Message: myMessage };
};
```

Note that this must be an async function even though I'm not actually waiting on any promises. If it's not an async function, the lambda runtime will expect a callback function and nothing will be returned.

I still have a couple of issues. According to my `tsconfig.json`, I don't allow implicit any types and according to my linting rules, functions must specify a return type. There are a number of ways to solve these problems, including changing the rules, but I will opt to take advantage of TypeScript and add a `types.ts` file to be shared by my lambdas. This will have no effect on my transpiled code but will give me the type safety I want while I'm developing.

Looking over the tutorial functions, I came up with two types that should serve my purposes:

```typescript
export interface ICase {
  Case: string;
  Message: string;
  Status?: number;
}

export interface IInput {
  inputCaseID: string;
}
```

My lambda with the types added looks like this. Now it passes linting and compiler checks.

```typescript
import { ICase, IInput } from './types';

export const handler = async (event: IInput): Promise<ICase> => {
  // Create a support case using the input as the case ID, then return a confirmation message
  const myCaseID = event.inputCaseID;
  const myMessage = 'Case ' + myCaseID + ': opened...';
  return { Case: myCaseID, Message: myMessage };
};
```

I give the same treatment to the other four lambdas like so: [src/lambda](https://github.com/elthrasher/cdk-step-functions-example/tree/master/src/lambda).

## More Tests <a name="more-tests"></a>

So far I've got one dummy test that checks to see if my empty CDK project is indeed empty. I've introduced some application code and I can easily test it with the same test suite. One thing I'm going to change first thing is the project is set up to find tests in `./tests`. I like putting my tests next to my source code. Just a matter of personal preference. I'll update `jest.config.js` to look for my tests in `<rootDir>/src` instead of `<rootDir>/test` and move the one test I have over, then remove the unused `test` directory.

To test this function, I want to assert that when it receives an input of `{ "inputCaseID": "001" }`, it should respond with `{ "Case": "001", "Message": "Case 001: opened..." }`. I could inline the input, but (as I discovered later in the process) it's nice to have input files for SAM, so I ended up writing my test like this:

```typescript
import input from '../../inputs/openCase.json';
import { handler } from './open-case';

test('Open Case Function Handler', async () => {
  const result = await handler(input);
  expect(result).toEqual({
    Case: '001',
    Message: 'Case 001: opened...',
  });
});
```

Remember enabling `resolveJsonModule` in my `tsconfig.json`? This is why. The json file I'm importing is simply `{ "inputCaseID": "001" }`.

The rest of my lambda tests are in [src/lambda](https://github.com/elthrasher/cdk-step-functions-example/tree/master/src/lambda) and the json files are in [inputs](https://github.com/elthrasher/cdk-step-functions-example/tree/master/inputs).

## CDK Lambda <a name="cdk-lambda"></a>

So I've got working (I think) lambdas. It's time to finally write some CDK. To prep for this task, I checked out a pretty good [example](https://github.com/aws-samples/aws-cdk-examples/tree/master/typescript/api-cors-lambda-crud-dynamodb) in the aws cdk examples repo. Looks pretty straightforward. I'm going to need to instantiate each lambda in turn and then create a Step Functions Task object for each.

```typescript
import { AssetCode, Function, Runtime } from '@aws-cdk/aws-lambda';

// other imports and stuff

const openCaseLambda = new Function(this, 'openCaseFunction', {
  code: new AssetCode(lambdaPath),
  handler: 'open-case.handler',
  runtime: Runtime.NODEJS_12_X,
});
```

To import the lambda types, I need to `npm i @aws-cdk/aws-lambda`. The rest of this is quite simple. The Function constructor takes three arguments, the scope, or current Stack I'm working on, the name I'm giving to the Function, and a config object. I'm putting a code path into the config object, a reference to the lambda handler and the runtime. I've picked the latest available NodeJS runtime.

`lambdaPath` is a local var that I created in order to make sure my code grabs the transpiled version which is going to be in my build output directory. I defined it with ``const lambdaPath = `${__dirname}/lambda`;``.

## CDK Step Functions <a name="cdk-step-functions"></a>

Next I install another CDK library and create a Task object using my new lambda as an argument.

```typescript
import { Chain, Choice, Condition, Fail, StateMachine, Task } from '@aws-cdk/aws-stepfunctions';
import { InvokeFunction } from '@aws-cdk/aws-stepfunctions-tasks';

// other stuff

const openCase = new Task(this, 'Open Case', {
  task: new InvokeFunction(openCaseLambda),
});
```

A Task is a step in a state machine where some work is performed. The Task class extends State, the base class for Step Functions. We'll need to work with two more State extensions, Fail and Choice. Fail is an end state representing failure of the job. Choice is a branching step. The declarations for both are quite simple.

```typescript
const jobFailed = new Fail(this, 'Fail', {
  cause: 'Engage Tier 2 Support',
});

const isComplete = new Choice(this, 'Is Case Resolved');
```

Completing the other four Tasks for my other four lambdas, I now have five Tasks, a Fail and a Choice. My CDK is valid, but will still produce an empty CloudFormation template. I need to tie all this together into a state machine. I'll need to implement two more classes, Chain and StateMachine.

The Chain is where the steps of my state machine are defined.

```typescript
const chain = Chain.start(openCase)
  .next(assignCase)
  .next(workOnCase)
  .next(
    isComplete
      .when(Condition.numberEquals('$.Status', 1), closeCase)
      .when(Condition.numberEquals('$.Status', 0), escalateCase.next(jobFailed)),
  );
```

I start with `openCase`, move directly to `assignCase`, then `workOnCase`. I move from function to function passing any new information along. `workOnCase` has two possible outcomes. Either the case is closed or it must be escalated to tier 2 support. Here's where my Choice step comes into play. The Choice receives arguments the same as my lambdas but without actually needing to invoke a lambda. It makes a decision based on the input given and calls the appropriate next function in the chain. In this case, we'll go to `closeCase` if the case was closed or `escalateCase` followed by `jobFailed` if it wasn't.

All that's left is to define my state machine.

```typescript
new StateMachine(this, 'StateMachine', {
  definition: chain,
});
```

Pretty simple. You can find the [completed stack](https://github.com/elthrasher/cdk-step-functions-example/blob/master/src/cdk-step-stack.ts) in my github repo.

Now we can see some CDK magic. When I `cdk synth` my 81 line TypeScript file becomes a 417 line yaml file. Assuming I have a credentialed AWS account, all I have to do to deploy is `cdk deploy`.

## IAM Roles <a name="iam-roles"></a>

There's a section in the [tutorial](https://aws.amazon.com/getting-started/tutorials/create-a-serverless-workflow-step-functions-lambda/) I used as a base that describes how to create an IAM Role for your state machine. As long as we have no special IAM needs, we don't need to do that with CDK! CDK assumes you want the minimum roles needed for your state machine to function and creates them automatically. When I deploy my stack, I find I have six new roles created.

If my stack wanted to persist data to a database such as DynamoDB and implement that logic in some of the lambda functions, then I _would_ need to include roles in my CDK code as CDK doesn't parse the lambda body. However, the code required is [quite intuitive](https://github.com/aws-samples/aws-cdk-examples/blob/master/typescript/api-cors-lambda-crud-dynamodb/index.ts#L73).

## Testing CDK <a name="testing-cdk"></a>

I already wrote tests for each of the lambdas. By now my "assert CDK is empty" test is going to be failing. I'd like it to pass. CDK comes with its own testing library with functions like `matchTemplate`. You can [read more about it here](https://docs.aws.amazon.com/cdk/latest/guide/testing.html). I ran the test that came with the generated project, copied the failure output, cleaned it up a little and [put it back in my test](https://github.com/elthrasher/cdk-step-functions-example/blob/master/src/cdk-step-stack.test.ts). I also changed `MatchStyle` from `EXACT` to `NO_REPLACES` which means my test will pass if some of the values (like generated S3 paths) change, but it'll fail if I add new resources or change any of the existing ones.

I'm not really convinced of the usefulness of this test but it didn't take long to write. If I started putting conditional logic or looping into my CDK, I'm sure it'll prove valuable.

## Deploy and Run <a name="deploy-and-run"></a>

The great thing about serverless is you can deploy your stack and run it a few times for free. No need to even clean up. That said, if you're setting up an AWS account for the first time, it might be a good idea to go to the [billing section](https://console.aws.amazon.com/billing/home#/) where you can set alarms and limits to make sure you aren't surprised by the cost of an experiment run amok.

If you haven't used aws cli before, you'll need to [configure your account](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) for local use. Note that there are a [couple of gotchas](https://github.com/aws/aws-cdk/issues/1656) when it comes to using CDK from the command line.

If you've never used CDK before, you'll need to `cdk bootstrap` which sets up an S3 bucket for staging artifacts. If you skip this step, you'll be informed that you have to do it, so no worries if you aren't sure. Then `cdk deploy` makes the magic happen. It doesn't take long to deploy - about a minute for me.

Now that my stack has deployed, I can explore it in the AWS Console and give it a try. Navigate to the Step Function in the console and click "Start execution".

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/8c8w91p3yb29pzyv7yrc.png)

I'll use `{ "inputCaseID": "001" }` as the payload for my first run, click "Start execution" and I'm rewarded with:

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/rzn0xfg3sqdp7yzg37sr.png)

It failed! That's okay. The logic in `work-on-case.ts` randomizes the outcome. It'll pass half the time and fail half the time. If I run it a few more times, I'll see it pass:

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/3pvr2m3mvh6n6gx2dnbr.png)

Clicking around in the console is a great way to really learn how step functions work. I can see the inputs and outputs for each step. I can also edit my state machine right there in the console, but of course I don't want to do this because I'd rather do it with CDK!

## SAM - Serverless Application Model <a name="sam"></a>

Since I'm using lambdas, it's a good idea to talk about SAM. In working through the steps above, I created lambdas, wrote unit tests for them and then deployed them without ever actually invoking them as lambda functions (unit tests are still worthwhile, but don't guarantee success as they don't use the lambda runtime).

[SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) is another abstraction on top of CloudFormation and it has its own CLI. It's probably best to let the [FAQ speak for itself](https://aws.amazon.com/cdk/faqs/#CloudFormation_and_SAM). There's some more information about [how to integrate the two](https://docs.aws.amazon.com/cdk/latest/guide/tools.html#sam) in the CDK documentation.

SAM doesn't understand CDK code, but CDK can produce a template that SAM can work from. If I want to run one of my lambdas with SAM, I need to [install the CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html), then execute these commands:

```bash
$ npm run build
$ cdk synth --no-staging > template.yaml
$ sam local invoke openCaseFunction64DF09DE -e ./inputs/openCase.json
Invoking open-case.handler (nodejs12.x)

Fetching lambci/lambda:nodejs12.x Docker container image......
Mounting /Users/matt.morgan/mine/cdk-step/build/src/lambda as /var/task:ro,delegated inside runtime container
START RequestId: d37ad83f-15c0-1794-bddf-88a433b3db92 Version: $LATEST
END RequestId: d37ad83f-15c0-1794-bddf-88a433b3db92
REPORT RequestId: d37ad83f-15c0-1794-bddf-88a433b3db92	Init Duration: 157.85 ms	Duration: 6.35 ms	Billed Duration: 100 ms	Memory Size: 128 MB	Max Memory Used: 39 MB

{"Case":"001","Message":"Case 001: opened..."}
```

The json files I created earlier are very useful.

## Next Steps <a name="next-steps"></a>

A state machine that can only be invoked from the console is pretty much only useful for learning. It would be interesting to explore different triggers as well as some persistence layer. The state machine should also have logging, traceability and analytics.

So far CDK has met my expectations as a new way to think about infrastructure as code and I'm eager to apply it to real-world use cases. If any readers have experience with CDK, please share in the comments.

Cover Image - https://www.dreamstime.com/monster-illustrations-black-white-monsters-creatures-battle-public-domain-image-free-83001740