Lambda-Backed Custom Resource Example with CDK

**Custom resources** are an AWS CloudFormation feature that allows you to execute custom logic during the creation, updating, and deletion of CloudFormation stacks.

Sometimes, it’s useful to execute custom logic while provisioning CloudFormation stacks. For example, what if you need to create resources that are not natively supported by CloudFormation?

In this post, I’ll demonstrate a sample project that initializes data in an Amazon DynamoDB table using a custom resource backed by AWS Lambda.

# Project overview

You can find the sample source code introduced in this post in [my GItHub repository](https://github.com/toru-iwasa/lambda-custom-resource-sample).

```bash
git clone https://github.com/toru-iwasa/lambda-custom-resource-sample.git
```

```bash
cd lambda-custom-resource-sample
```

## Project Structure

Here’s the project structure:

```bash
lambda-custom-resource-sample/
├── README.md
├── bin
│   └── lambda-custom-resource-sample.ts
├── cdk.json
├── lib
│   ├── lambda
│   │   ├── index.ts
│   │   └── package.json
│   └── lambda-custom-resource-sample-stack.ts
├── package.json
├── test
│   └── lambda-custom-resource-sample.test.ts
└── tsconfig.json
```

The key files for this project are `lambda-custom-resource-sample-stack.ts` and `lib/lambda/index.ts`.

## `lambda-custom-resource-sample-stack.ts`

This file difines the following:

1. DynamoDB Table
A `GameTable` is defined with two fields:
    - `PlayerId` (partition key)
    - `Level` (sort key)
1. Lambda function
A singleton lambda function, `PutItemLambda`, is defined as part of the stack.
1. Granting Permissions
The Lambda function is granted read and write permissions for the `GameTable`.
1. Custom resource
A custom resource is defined to pass properties (`tableName`, `playerId`, and `level`)  for initializing data.
1. Dependency Management
The custom resource ensures permissions are granted before creating other resources (even if this step may not be nessesary in this example).

Here’s the code:

```typescript
import {
  CustomResource,
  Duration,
  RemovalPolicy,
  Stack,
  StackProps,
} from "aws-cdk-lib";
import {
  AttributeType,
  BillingMode,
  Table,
} from "aws-cdk-lib/aws-dynamodb";
import {
  SingletonFunction,
  Architecture,
  Runtime,
  Code,
} from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
import path = require("path");

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

    const gameTable = new Table(this, "GameTable", {
      partitionKey: {
        name: "PlayerId",
        type: AttributeType.STRING,
      },
      sortKey: {
        name: "Level",
        type: AttributeType.NUMBER,
      },
      billingMode: BillingMode.PAY_PER_REQUEST,
      removalPolicy: RemovalPolicy.RETAIN,
    });

    const putItemLambda = new SingletonFunction(this, "PutItemLambda", {
      architecture: Architecture.ARM_64,
      runtime: Runtime.NODEJS_LATEST,
      handler: "index.handler",
      code: Code.fromAsset(path.join(__dirname, "lambda")),
      lambdaPurpose: "PutItemLambda",
      timeout: Duration.minutes(10),
      uuid: "7acd8116-b39b-463d-932c-2e61f824067b",
    });

    const grant = gameTable.grantReadWriteData(putItemLambda);

    const gameTableItem = new CustomResource(this, "GameTableItem", {
      serviceToken: putItemLambda.functionArn,
      serviceTimeout: Duration.minutes(5),
      resourceType: "Custom::GameTableItem",
      properties: {
        tableName: gameTable.tableName,
        playerId: "P000001",
        level: 1,
      },
    });

    gameTableItem.node.addDependency(grant);
  }
}
```

---

## `lib/lambda/index.ts`

This file implements the Lambda function used for custom resource. Key responsibilities of the Lambda function include:

1. Handling Custom Resource Requests
The Lmabda function receives and processes `Create`, `Update`, and `Delete` events.
1. Executing Custom Logic
    - **Create Event**: Adds the item specified in the `properties` field to the DynamoDB table.
    - **Delete** Event: Deletes the item from the DynamoDB table if it exists.
1. Sending Responses
Return a `SUCCESS` or `FAILED` response to the `ResponseURL`.

Here’s the code:

```typescript
import { CloudFormationCustomResourceEvent, Handler } from "aws-lambda";
import {
  DynamoDBClient,
  ResourceNotFoundException,
  waitUntilTableExists,
} from "@aws-sdk/client-dynamodb";
import {
  PutCommand,
  DynamoDBDocumentClient,
  PutCommandInput,
  DeleteCommand,
  DeleteCommandInput,
  GetCommand,
  GetCommandInput,
} from "@aws-sdk/lib-dynamodb";

interface PutItemLambdaProps {
  tableName: string;
  playerId: string;
  level: number;
}

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

async function sendResponse(
  event: CloudFormationCustomResourceEvent<PutItemLambdaProps>,
  status: string,
  reason: string | null
): Promise<void> {
  console.log("sendResponse: ", event, status, reason);

  const physicalResourceId = (() => {
    switch (event.RequestType) {
      case "Create":
        return event.LogicalResourceId;
      case "Update":
      case "Delete": {
        return event.PhysicalResourceId;
      }
    }
  })();

  const responseBody = JSON.stringify({
    Status: status,
    Reason: reason,
    PhysicalResourceId: physicalResourceId,
    StackId: event.StackId,
    RequestId: event.RequestId,
    LogicalResourceId: event.LogicalResourceId,
  });

  const result = await fetch(event.ResponseURL, {
    method: "PUT",
    body: responseBody,
    headers: {
      "Content-Type": "application/json",
      "Content-Length": responseBody.length.toString(),
    },
  });

  console.log(result);
}

const handleCreate = async (
  event: CloudFormationCustomResourceEvent<PutItemLambdaProps>
) => {
  const props = event.ResourceProperties as PutItemLambdaProps;

  const waitResult = await waitUntilTableExists(
    { client: client, maxWaitTime: 60 },
    { TableName: props.tableName }
  );

  console.log("wait result: ", waitResult);

  if (waitResult.state !== "SUCCESS") {
    throw new Error(waitResult.reason);
  }

  const command = new PutCommand({
    TableName: props.tableName,
    Item: {
      PlayerId: props.playerId,
      Level: Number(props.level),
    },
  } as PutCommandInput);

  await docClient.send(command);
};

const handleDelete = async (
  event: CloudFormationCustomResourceEvent<PutItemLambdaProps>
) => {
  const props = event.ResourceProperties as PutItemLambdaProps;

  const input = {
    TableName: props.tableName,
    Key: {
      PlayerId: props.playerId,
      Level: Number(props.level),
    },
  };

  const getCommand = new GetCommand(input as GetCommandInput);
  await docClient
    .send(getCommand)
    .then(async () => {
      const deleteCommand = new DeleteCommand(input as DeleteCommandInput);
      await docClient.send(deleteCommand);
    })
    .catch((error: ResourceNotFoundException) => {
      console.log("Item not found: ", error.message);
    });
};

export const handler: Handler = async (
  event: CloudFormationCustomResourceEvent<PutItemLambdaProps>
): Promise<void> => {
  console.log(event);
  try {
    switch (event.RequestType) {
      case "Create": {
        console.log("RequestType: Create");
        await handleCreate(event);
        await sendResponse(event, "SUCCESS", "TableItem successfully created.");
        break;
      }
      case "Update": {
        console.log("RequestType: Update");
        await sendResponse(event, "SUCCESS", "Update not implemented.");
        break;
      }
      case "Delete": {
        console.log("RequestType: Delete");
        await handleDelete(event);
        await sendResponse(event, "SUCCESS", "TableItem successfully deleted.");
        break;
      }
    }
  } catch (error: any) {
    console.error("Error: ", error);
    await sendResponse(event, "FAILED", error.message);
  }
};

```

# Deploy Stack

## Steps to Deploy

1.&nbsp;Install dependencies:
```bash
npm install && npm install --prefix lib/lambda
```

2.&nbsp;Build the project:
```bash
npm run build
```
3.&nbsp;Bootstrap the CDK environment (if this is your first CDK project):
```bash
cdk bootstrap
```
4.&nbsp;Deploy the stack:
```bash
cdk deploy
```

# Verify the DynamoDB Table

Once the deployment is complete, navigate to the DynamoDB console. Select the `GameTable`, click **Explore table items**, and verify the item have been created successfully.

![20250126_0_GameTable.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3816889/bd98ddbe-c098-c828-fbb4-50efd9fb2c7c.png)

![20250126_1_GameTableItem.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3816889/7b5f19f0-63de-f526-0f6e-13f6277fdf95.png)

# Table Item Deletion

When the CDK stack destroyed, the `GameTable` will be retained because its removal policy is set to `RETAIN`. This ensures that the table is not deleted alog with the stack, allowing yout to verify that the item successfully deleted from the table.

## Destroy the CDK Stack

Run the following command to destroy the stack:

```bash
cdk destroy
```

Once the destruction completed, go back to the `GameTable` of DynamoDB Console and verify the item have been successfully deleted.

![20250126_2_GameTableItem_deleted.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3816889/da958b93-0172-c9db-5e02-ba127800e444.png)

# Clean up

## Delete the DynamoDB Table

To delete the `GameTable`, navigate to the DynamoDB Console, select the table, and choose **Actions** > **Delete table.** Enter `confirm`  when prompted, and click **Delete**.

## Delete the CloudWatch Log group

The CloudWatch Log group created for the Lambda function is not part of the stack and must be deleted manually.

1. Open the CloudWatch console and navigate to **Log groups**
2. Find the Log Group with a name starting with  `/aws/lambda/LambdaCustomResourceSampl-PutItemLambda...`
3. Select the log group, click **Actions**, and choose **Delete log group**

# Wrap up

In this blog post, I demonstrated a sample project using Lambda backed custom resources with AWS CDK.

With this knowledge, you can now develop Lambda-backed CloudFormation custom resources and execute any custom logic you need. Feel free to use teh provided sample code in your own projects.

Happy coding, and enjoy developing!

# References

* [AWS CloudFormation Custom Resources Documentation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html)
* [AWS CloudFormation Resource Type Reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html)
* [AWS CDK CustomResource Documentation](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.CustomResource.html)
* [AWS CDK SingletonFunction Documentation](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.SingletonFunction.html)
* [AWS CDK Permissions Grants](https://docs.aws.amazon.com/cdk/v2/guide/permissions.html#permissions_grants)