How to use Attini with the AWS CDK

In this guide we will demonstrate how to use the Attini CDK constructs to create a deployment plan. We will also use the AttiniCdk step to deploy a CDK app.

Introduction

The AWS CDK is a way to create cloud resources using different programming languages instead of having to write CloudFormation directly. Attini supports both writing deployment plans using the CDK and deploying CDK apps as a part of a deployment plan.

How it works

Attini provides its own CDK construct in order to make it easy to use its various features. Currently, the constructs are available in the following languages:

  • TypeScript
  • JavaScript
  • Python
  • Java
  • Go

Check the GitHub page for instructions on how to install them in different languages.

In order to use the CDK to synthesize a deployment plan, you will need the CDK CLI installed. To deploy a CDK app, you will also need to bootstrap the CDK in your AWS account.

When writing a deployment plan using the CDK, we utilize the synthesizing feature of the CDK to create the deployment plan template. However, we still use Attini to create a distribution and deploy it. So the workflow looks something like this:

flowchart LR 1[Begin packaging] --> 2[Synthesize deployment plan using the CDK] 2 --> 3[Finish packaging] 3 --> 4[Deploy package]

Synthesizing the deployment plan template is easy to do as a pre-package command in the Attini configuration file. For a Python project, it could look like this:

distributionName: cdk-demo
package:
  prePackage:
    commands:
      - cdk synth --app "python3 deployment_plan.py" > deployment-plan.yaml

initDeployConfig:
  template: deployment-plan.yaml
  stackName: ${environment}-${distributionName}-deployment-plan

Above we output the synthesized template to the “deployment-plan.yaml” file and then specify that file as our initDeployConfig template.

The deployment plan must be environment-agnostic, meaning no environment-specific resources should be used when synthesizing the template. Anything environment-specific should instead be handled during the execution of the deployment plan. This is an important distinction between writing a deployment plan with the CDK and deploying your AWS resources with an CDK app.

Writing a deployment plan with the CDK

When writing the deployment plan using the Attini CDK constructs, the deployment plan app has to be environment-agnostic. Because one of the core concepts of Attini is that the same distribution should be deployable in all your environments, the deployment plan template can’t contain any environment-specific values. Anything environment-specific should instead be configured in the Attini configuration file or be resolved during the execution of the deployment plan.

Deploying a CDK app

When you deploy a CDK app with AttiniCdk type, the CDK app will be synthesized and deployed from within your AWS environment. So CDK app can use lookups and other environment-specific functions.

Since the deployment plan should be synthesized separately from any CDK stacks you wish to deploy, it is recommended to create a separate app file for the deployment plan. The deployment plan app and your cdk app can still be part of the same distribution and CDK project, as we will see in the demo.

Demo

In this demo we will first create a deployment plan using the Attini CDK constructs. The deployment plan will contain three steps that perform different tasks. The first step will deploy a CloudFormation template containing a DynamoDB table. The second step will deploy a CDK app containing a Lambda function interacting with the DynamoDB table. That means that we will need to pass some values from the CloudFormation template to the CDK app. The third step will simply call the Lambda function we created in the second step.

So the deployment plan will look like this:

stateDiagram-v2 s1 : Deploy DynamoDB CFN template s2 : Deploy Lambda Function CDK app s3 : Invoke Lambda function s1 --> s2: Pass table Arn s2 --> s3: Pass function name

You can find a full working example on GitHub.

First, let’s create our project. Create a folder and initialize a CDK app in it. We must also add the Attini constructs to the CDK app by running npm i @attini/cdk. Finally, we create a new folder to keep our deployment plan in.

mkdir attini-cdk-demo
cd attini-cdk-demo
cdk init --language typescript
npm i @attini/cdk
mkdir deployment-plan

Next, we need to create a new app and stack for the deployment plan. Because it needs to be synthesized separately from the rest of the CDK app, it needs to be given its own app in a separate file.

Let’s create a new TypeScript file in the deployment-plan folder called deployment-plan.ts.

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AttiniCfn, AttiniDeploymentPlanStack, DeploymentPlan } from '@attini/cdk';
import { Construct } from 'constructs';

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

    const deployDatabaseStep = new AttiniCfn(this, 'DeployDatabase', {
      stackName: "demo-database",
      template: "/database.yaml"
    });
    
    new DeploymentPlan(this, 'deploymentPlan', {
      definition: deployDatabaseStep
    })
  }
}

const app = new cdk.App();
new DeploymentPlanStack(app, 'DeploymentPlanStack', {});

A few things are happening here. First, notice that the DeploymentPlanStack extends AttiniDeploymentPlanStack. AttiniDeploymentPlanStack is just an extension of a normal Stack but will add some transforms to make it work with the Attini framework.

Next, we created an AttiniCfn object. This is the CDK construct for deploying old-school CloudFormation templates with Attini. It has a lot of configuration options, but at a minimum it needs to know the location of the template to deploy and what to name the stack. The template path is always specified from the root of the project. We will create the actual templates it deploys in a minute.

Lastly, we created the DeploymentPlan object and passed it the deployment plan definition. We currently only have one step, but we will add more steps later to demonstrate how to chain them together and pass values between them.

Now that we have the deployment plan, let’s add the CloudFormation template. It contains a simple DynamoDB table and will output the table Arn for other resources to use. Create a new file in the root of the project called database.yaml and add the following CloudFormation to it:

AWSTemplateFormatVersion: '2010-09-09'
Description: Simple database

Resources:
  Database:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: Id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: Id
          KeyType: HASH
          
Outputs:
  DatabaseArn:
    Description: "Database arn"
    Value: !GetAtt Database.Arn

Finally, we need an attini-config.yaml file in the root of the project:

distributionName: cdk-demo
package:
  prePackage:
    commands:
      - npm install
      - cdk synth --app "npx ts-node --prefer-ts-exts deployment-plan/deployment-plan.ts" > deployment-plan.yaml

initDeployConfig:
  template: deployment-plan.yaml
  stackName: ${environment}-${distributionName}-deployment-plan

We synthesize the deployment plan template as a pre-package command. This means that the synth command will be executed in the beginning of the packaging process every time we package a distribution.

In the synth command we use --app option to specify that we want to synthesize our deployment plan and not the app that specified in the cdk.json file. The cdk.json file points towards “bin/attini-cdk-demo.ts”, which is the app that we will deploy later. If we only wanted to use the CDK to create our deployment plan (which we could!), then we would not need two separate apps and could simplify this.

So, now our project should look something like this (node_modules and some other none important file has been excluded):

.
├── cdk.json
├── attini-config.yaml
├── bin
│   └── attini-cdk-demo.ts
├── lib
│   └── attini-cdk-demo-stack.ts
├── database.yaml
├── deployment-plan
│   └── deployment-plan.yaml
├── package-lock.json
├── package.json
└── tsconfig.json

That’s all we need. The deployDatabaseStep will deploy the template. We can now package and deploy the distribution. Run the attini deploy run command from the root of the project.

attini deploy run .
image of deployment plan result

So far so good, deploying the stack went well. But we haven’t actually deployed the CDK app yet, we just used the CDK to create a deployment plan, which in turn deployed a CloudFormation template.

Let’s add some resources to the attini-cdk-demo-stack.ts file. We will create a Lambda function that will interact with the DynamoDB table we created.

First, we need to add some code to our CDK app. Let’s update the attini-cdk-demo-stack.ts file:

import * as cdk from 'aws-cdk-lib';
import { CfnOutput, CfnParameter } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Table } from 'aws-cdk-lib/aws-dynamodb';

export class AttiniCdkDemoStack extends cdk.Stack {
  public static readonly databaseArn: string = 'DatabaseArn';
  public static readonly functionNameOutputId: string = 'FunctionName';

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const databaseArn = new CfnParameter(this, AttiniCdkDemoStack.databaseArn, {
      type: 'String'
    });

    const functionRole = new Role(this, 'FunctionRole', {
      assumedBy: new ServicePrincipal('lambda.amazonaws.com')
    });

    const table = Table.fromTableArn(this, 'DynamoTable', databaseArn.valueAsString)
    table.grantReadWriteData(functionRole);

    const lambdaFunction = new Function(this, 'DemoLambda', {
      runtime: Runtime.NODEJS_18_X,
      code: Code.fromAsset("./src"),
      handler: 'index.handler',
      environment: {DATABASE_NAME: table.tableName},
      role: functionRole
    });

    new CfnOutput(this, AttiniCdkDemoStack.functionNameOutputId, {value: lambdaFunction.functionName})
  }
}

The stack has a CfnParameter called databaseArn which will be provided by the deployment plan payload. The parameter name (AttiniCdkDemoStack.databaseArn) is declared as a public read only variable, that way the deployment plan can access it. The stack creates a Role for the function and uses the Table Interface to grant it read/write access to our DynamoDB table. It then creates a Lambda function with the environment variable DATABASE_NAME so that the table name can be read in the function code.

The function gets its code from the src folder, and a file called index.js, so let’s create it.

mkdir src
touch src/index.js

Now add the following code to src/index.js.

const {DynamoDBClient} = require("@aws-sdk/client-dynamodb");
const {PutItemCommand} = require("@aws-sdk/client-dynamodb");
const {GetItemCommand} = require("@aws-sdk/client-dynamodb");

const dynamoDBClient = new DynamoDBClient({});

exports.handler = async function () {
    
    const getItemCommand = new GetItemCommand({
        TableName: process.env.DATABASE_NAME,
        Key: {Id: {'S': 'counter-id'}}
    });

    const counter = await dynamoDBClient.send(getItemCommand)
        .then(value => {
            let currentCounter = value.Item === undefined ? 0 : value.Item['Counter'].N;
            return ++currentCounter;
        })
        .then(value => putCounterValue(value));

    return 'The function has been invoked ' + counter + ' times';
}

async function putCounterValue(value) {
    
    const putItemCommand = new PutItemCommand({
        TableName: process.env.DATABASE_NAME,
        Item: {
            'Id': {S: 'counter-id'},
            'Counter': {N: value.toString()}
        }
    });
    
    return dynamoDBClient.send(putItemCommand)
        .then(() => value);
}

The code above will read a counter from the database and then update it, so that we can see how many times the function has been called. How to write a Lambda function is a bit out of scope for this demo, so I will not go into any more details.

Now let’s add our CDK app to the deployment plan by creating a step called DeployCdkApp of the AttiniCdk type.

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AttiniCdk, AttiniCfn, AttiniDeploymentPlanStack, AttiniLambdaInvoke, DeploymentPlan } from '@attini/cdk';
import { Construct } from 'constructs';
import { AttiniCdkDemoStack } from '../lib/attini-cdk-demo-stack';
import { JsonPath } from 'aws-cdk-lib/aws-stepfunctions';

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

    const deployDatabaseStep = new AttiniCfn(this, 'DeployDatabase', {
      stackName: "demo-database",
      template: "/database.yaml"
    });


    const deployCdkStep = new AttiniCdk(this, 'DeployCdkApp', {
      path: '.',
      buildCommands: 'npm install',
      stackConfiguration: [
        {
          parameters: {
            [AttiniCdkDemoStack.databaseArn]: deployDatabaseStep.getOutput('DatabaseArn')
          }
        }
      ]
    });
    
    new DeploymentPlan(this, 'deploymentPlan', {
      definition: deployDatabaseStep.next(deployCdkStep)
    })
  }
}

const app = new cdk.App();
new DeploymentPlanStack(app, 'DeploymentPlanStack', {});

In order to pass the database Arn to the CDK app, we need to read it from “deployDatabaseStep”. As you can see in the database.yaml template, we already output this value from the stack, so we just need to pass it to the “deployCdkStep”. There are two good ways of doing this:

  1. Pass the values as parameters to the CDK stacks.
  2. Set them as environment variables that the CDK can read.

What method to choose depends on your use case and personal preference. If you use the environment variables approach, you probably want to add some error handing in your CDK stack in case the value is missing, so in this demo we will pass the values as parameters. That way the deployment will fail if the values aren’t passed correctly.

Now let’s look at the new AttiniCdk step. This is a type of Attini step specifically built for deploying CDK apps. It will start a container (an Attini runner) in your AWS account and perform the deployment from there, meaning we can do environment-specific stuff, like reading from SSM Parameter store etc. Because we are using TypeScript, we need to run “npm install” before deploying, so we added that as a build command. We also provide the path to the new CDK app. Finally, we read the parameter values from the output of the deployDatabaseStep. Because the key names are static, we can read them from the AttiniCdkDemoStack stack.

The getOutput method exists on all Attini steps that have an output and will read a string value from the output.

Also note that we used the next method of the deployDatabaseStep to chain the steps together.

Finally, let’s invoke the Lambda function at the end of the deployment plan. We can do that by using the AttiniLambdaInvoke step. In order to invoke the Lambda function, we need to know its name, and we can read this from the CDK apps output. Attini will place the output of each stack the app contains in the payload under a key named after the stacks construct id. So in order to read from the CDK apps output, we need to know what the stack construct was named. To du his programmatically, we can simply export the name from attini-cdk-demo.ts where we create the stack.

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AttiniCdkDemoStack } from '../lib/attini-cdk-demo-stack';

const app = new cdk.App();

export const stackId: string = 'AttiniCdkDemoStack';

new AttiniCdkDemoStack(app, stackId);

Then we can read the id when we create the Lambda invocation step. Let’s add the step to the deployment plan:

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AttiniCdk, AttiniCfn, AttiniDeploymentPlanStack, AttiniLambdaInvoke, DeploymentPlan } from '@attini/cdk';
import { Construct } from 'constructs';
import { AttiniCdkDemoStack } from '../lib/attini-cdk-demo-stack';
import { stackId } from '../bin/attini-cdk-demo';

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

    const deployDatabaseStep = new AttiniCfn(this, 'DeployDatabase', {
      stackName: "demo-database",
      template: "/database.yaml"
    });

    const deployCdkStep = new AttiniCdk(this, 'deploy-cdk-app', {
      path: './',
      buildCommands: 'npm install',
      stackConfiguration: [
        {
          parameters: {
            [AttiniCdkDemoStack.databaseArn]: deployDatabaseStep.getOutput('DatabaseArn')
          }
        }
      ]
    });

    const invokeLambdaStep = new AttiniLambdaInvoke(this, 'InvokeLambda', {
      functionName: deployCdkStep.getOutput(stackId, AttiniCdkDemoStack.functionNameOutputId)
    });

    new DeploymentPlan(this, 'DeploymentPlan', {
      definition: deployDatabaseStep.next(deployCdkStep).next(invokeLambdaStep)
    })
  }
}

const app = new cdk.App();
new DeploymentPlanStack(app, 'DeploymentPlanStack', {});

The AttiniLambdaInvoke step is fairly straight forward, it simply needs the name of the Lambda function it should invoke.

Let’s run the deployment again.

image of deployment plan result

Success! The deployment plan also deployed our CDK app this time!

So now we’ve used three different types of steps to perform three different tasks, we:

  1. Deployed a traditional CloudFormation template.
  2. Deployed a CDK app.
  3. Invoked a Lambda Function.

We also used the deployment plan payload to pass values between the steps. All Attini types have their own corresponding CDK construct, so it is easy to extend the deployment plan with new tasks.

You can find a full working example on GitHub.

Example files

attini-config.yaml
distributionName: cdk-demo
initDeployConfig:
  template: deployment-plan.yaml
  stackName: ${environment}-${distributionName}-deployment-plan

package:
  prePackage:
    commands:
      - cd deployment-plan
      - npm install
      - cdk synth > ../deployment-plan.yaml
deployment-plan.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AttiniCdk, AttiniCfn, AttiniDeploymentPlanStack, AttiniLambdaInvoke, DeploymentPlan } from '@attini/cdk';
import { Construct } from 'constructs';
import { AttiniCdkDemoStack } from '../lib/attini-cdk-demo-stack';
import { stackId } from '../bin/attini-cdk-demo';

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

    const deployDatabaseStep = new AttiniCfn(this, 'DeployDatabase', {
      stackName: "demo-database",
      template: "/database.yaml"
    });

    const deployCdkStep = new AttiniCdk(this, 'DeployCdkApp', {
      path: './',
      buildCommands: 'npm install',
      stackConfiguration: [
        {
          parameters: {
            [AttiniCdkDemoStack.databaseArn]: deployDatabaseStep.getOutput('DatabaseArn')
          }
        }
      ]
    });

    const invokeLambdaStep = new AttiniLambdaInvoke(this, 'InvokeLambda', {
      functionName: deployCdkStep.getOutput(stackId, AttiniCdkDemoStack.functionNameOutputId)
    });

    new DeploymentPlan(this, 'DeploymentPlan', {
      definition: deployDatabaseStep.next(deployCdkStep).next(invokeLambdaStep)
    })
  }
}

const app = new cdk.App();
new DeploymentPlanStack(app, 'DeploymentPlanStack', {});
attini-cdk-demo-stack.ts
import * as cdk from 'aws-cdk-lib';
import { CfnOutput, CfnParameter } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Table } from 'aws-cdk-lib/aws-dynamodb';

export class AttiniCdkDemoStack extends cdk.Stack {
  public static readonly databaseArn: string = 'databaseArn';
  public static readonly functionNameOutputId: string = 'functionName';

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const databaseArn = new CfnParameter(this, AttiniCdkDemoStack.databaseArn, {
      type: 'String'
    });

    const functionRole = new Role(this, 'functionRole', {
      assumedBy: new ServicePrincipal('lambda.amazonaws.com')
    });

    const table = Table.fromTableArn(this, 'dynamoTable', databaseArn.valueAsString)
    table.grantReadWriteData(functionRole);

    const lambdaFunction = new Function(this, 'demoLambda', {
      runtime: Runtime.NODEJS_18_X,
      code: Code.fromAsset("./src"),
      handler: 'index.handler',
      environment: {DATABASE_NAME: table.tableName},
      role: functionRole
    });

    new CfnOutput(this, AttiniCdkDemoStack.functionNameOutputId, {value: lambdaFunction.functionName})
  }
}
database.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: >
    Contains a simple database

Resources:
  Database:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: Id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: Id
          KeyType: HASH
Outputs:
  DatabaseArn:
    Description: "Database arn"
    Value: !GetAtt Database.Arn
Lambda function code

const {DynamoDBClient} = require("@aws-sdk/client-dynamodb");
const {PutItemCommand} = require("@aws-sdk/client-dynamodb");
const {GetItemCommand} = require("@aws-sdk/client-dynamodb");


const dynamoDBClient = new DynamoDBClient({});

exports.handler = async function () {


    const getItemCommand = new GetItemCommand({
        TableName: process.env.DATABASE_NAME,
        Key: {Id: {'S': 'counter-id'}}
    });

    const counter = await dynamoDBClient.send(getItemCommand)
        .then(value => {
            let currentCounter = value.Item === undefined ? 0 : value.Item['Counter'].N;
            return ++currentCounter;
        })
        .then(value => putCounterValue(value));


    return 'The function has been invoked ' + counter + ' times';

}

async function putCounterValue(value) {
    const putItemCommand = new PutItemCommand({
        TableName: process.env.DATABASE_NAME,
        Item: {
            'Id': {S: 'counter-id'},
            'Counter': {N: value.toString()}
        }
    });
    return dynamoDBClient.send(putItemCommand)
        .then(() => value);
}
attini-cdk-demo.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AttiniCdkDemoStack } from '../lib/attini-cdk-demo-stack';

const app = new cdk.App();

export const stackId: string = 'AttiniCdkDemoStack';

new AttiniCdkDemoStack(app, stackId);