AWS CDK Custom Resources: Parsing Synth-time JSON

In a previous blog post I covered a basic overview of Custom Resources in AWS Cloud Development Kit (CDK) and in keeping with the same theme, I ran into a scenario which I thought would be more straightforward than it actually was: parsing a response containing a JSON object from a Lambda backed Custom Resource.

The problematic context was using a Custom Resource to fetch a secret from Parameter Store which was a JSON object. This object contained some setting and configuration details for AWS resources being deployed in a CDK Stack.  I thought I could simply create a Lambda backed Custom Resource which fetched the secret and returned it, so I could use it in the Stack and pass it to the resources which required that information. It turned out that it  was not so simple.

The Problem:

When you deploy your infrastructure with CDK and need to reference data stored in AWS Parameter Store, for instance, you cannot get direct access to the data in raw form at runtime. This is called “synthesis time” in CDK speak and it means that when you run a deployment, CDK synthesizes an AWS CoudFormation template (a YAML file) for the desired state of your infra with placeholder tokens for pieces of data which are not resolved until later in the physical resource creation process.

The two hurdles to get over were, as mentioned:

  • The value retrieved at synthesis time during deployment was an unresolved token, not the actual secret value.
  • The configuration stored in Parameter Store was not a simple string. It was in the form of a JSON object, and I needed to dig into the object to access specific properties off of it.

This meant that I could not leverage code constructs such as built-in parsing APIs for JSON or simple dot notation for property access with JavaScript, or easily work with in-memory data in an object type form as you normally would when writing application code.

The Solution:

CDK comes with AWS CloudFormation’s Intrinsic Functions, which you can use to work with and parse these unresolved placeholder tokens at synthesis time as if they were the resolved actual values at resource creation time.

In particular, the functions to use are cdk.Fn.split and cdk.Fn.select, which can be used to split the JSON string and select the value. This may not be the most elegant or robust way to handle parsing unresolved objects at synthesis time, but it worked, is practical and saved me further lost time exploring other solutions which just didn’t wind up working out (such as building a custom Lambda to use with a non pre-baked standard Custom Resource which parses and returns the object).

I also used the built-in Custom Resource AwsCustomConstruct constructs provided by CDK to fetch the parameter from Parameter Store.  What’s great about these built-in Custom Resources, is that you don’t have to create a Lambda, manage dependencies, or write any custom logic to accomplish common use case tasks. The pre-built Custom Resource is automatically backed by a Lambda or functionality to accomplish a given action (in this case, ‘getParameter’ to retrieve a parameter from AWS Parameter Store).  This saves you extra overhead and reduces moving parts in your IaC code.

In case it is helpful to others running into the problem, the code for the approach is below (using this example from the AWS CDK documentation as a reference):

As an example, the kind of data I was storing and retrieving from Parameter Store was something like:

{ "prop1": "prop1Value",  "prop2": "prop2value" }
// cdkProject/lib/myStack.ts

import * as cr from "aws-cdk-lib/custom-resources";

const JSON_PARAM_NAME = "/myapp/secrets/jsonsecret";

// Using the built-in AwsCustomResource construct with the action "getParameter":
    const getParameter = new cr.AwsCustomResource(this, "GetParameter", {
      onUpdate: {
        service: "SSM",
        action: "getParameter",
        parameters: {
          Name: JSON_PARAM_NAME,
          WithDecryption: true,  // Needed for SecureString parameters
        },
        physicalResourceId: cr.PhysicalResourceId.of(Date.now().toString()), // Forces fetch to parameter store on each deploy
      },
      policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
        resources: [
    `arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter${JSON_PARAM_NAME}`,
        ],
      }),
    });
// Parameter Store values are by default available on the Parameter.Value property of the response from SSM:
const paramValue = getParameter.getResponseField("Parameter.Value");

// Here we need to use intrinsic functions to dig into the future resolved actual value of the retrieved secret (which is a placeholder token at synth time):
    const prop1Split = cdk.Fn.split('"prop1"', paramValue);
    const afterKeyPart = cdk.Fn.select(1, prop1Split);  // Part after "prop1" key
    const afterColon = cdk.Fn.split(':', afterKeyPart);
    // the value part after the colon
    const valuePart = cdk.Fn.select(1, afterColon);
    const valueSplit = cdk.Fn.split('"', valuePart);
    const prop1Value = cdk.Fn.select(1, valueSplit); // First split part is the value for the property

    new cdk.CfnOutput(this, "TheParamValue", {
      value: prop1Value,
      description: "Parsed value from the custom resource",
    });

After implementing the use of Instrinsic Functions and leveraging the built-in Custom Resource Action CDK provides for fetching data from AWS Parameter Store, I was able to successfully retrieve and insert the important piece of configuration needed for my Resources.

If there are more robust ways to handle JSON object like pieces of data in a context like this, please feel free to leave suggestions in the comments, but this turned out to be a working solution that enabled me to move forward and deploy my properly configured infrastructure.

Understanding Custom Resources in AWS CDK

A Brief Introduction: AWS CDK

AWS Cloud Development Kit (CDK) is an open source Infrastructure as Code tool recommended and maintained by AWS to manage cloud infrastructure via code. It offers management using languages such as JavaScript, TypeScript, Python, C#, GoLang and Java which means you can take advantage of code constructs such as loops, variables and conditionals to orchestrate how your infrastructure such as EC2 instances, S3 Buckets, RDS instances and other AWS resources are managed, updated and deployed.

What is Infrastructure as Code?

IaC (Infra as Code) is an approach to managing resources needed to host and run your application or website using actual written code instead of, for example, manually ssh’ing into your web servers or logging into your Cloud console UI to turn buttons and knobs or do administration manually. It’s a modern, robust and more automated way to reduce user errors when doing this kind of manual maintenance or updating of your software infrastructure.

Traditionally, IaC management in AWS was done directly with AWS CloudFormation, which leverages YAML files and syntax. While this was a viable option, it could also be quite verbose, complicated and overwhelming with very large YAML files and a large array of properties and settings to memorize or look up.

AWS CDK actually still uses CloudFormation under the hood, but it abstracts the details and complexity away allowing you to use code in a variety of languages. The library comes with helper functions that encapsulate things like adding Allow IAM permissions to resources in one line of code, instead of having to construct a more complex and verbose YAML block, for instance.

Custom Resources

This brings us to the topic of this post which is a construct in AWS CDK called a Custom Resource. A Custom Resource is an entity in AWS CloudFormation which could be used to extend “out of the box” functionality available conventionally.  For example, if you needed to run some logic or encapsulate an infrastructure operation that didn’t come “out of the box” with a Resource in CloudFormation, you could use a Custom Resource to combine available Resources or run custom logic as part of your deployment process.

What is a Resource?

Here, we’re talking about logical resources, which means a resource that represents a physical one – for example, an AWS::EC2::Instance resource in CloudFormation is a representation of an actual EC2 instance that would be deployed into your AWS account’s VPC.  AWS CDK uses an abstraction called a Construct, which can represent a CloudFormation Logical Resource, but is a simpler object to deal with, coming with member functions and built in helpers to accomplish common infra management tasks that normally would be more cumbersome to implement in raw CloudFormation YAML templates.

With the background and terminology out of the way, now let’s understand why and when to use Custom Resources in CDK.

Let’s say you want to update a list of email addresses for a subscription to an AWS SNS topic, so that when you run a deployment, a list of emails is checked against to determine what set of users receive notifications or emails from the SNS topic when a message is published.

There is not a built in Resource to accomplish this, so you can make a Custom Resource backed by a AWS Lambda (a small serverless function) which could reach out to a data store (AWS Parameter Store, for instance), retrieve a list of emails and create a subscription in SNS for the emails when you release or update your application infrastructure. This would automate manual maintenance or updates to the SNS subscriptions where you’d otherwise have to login to AWS console, go through the steps in the SNS topics UI and start typing in email addresses and creating subscriptions manually.

A Concrete Example:

Implementing the above scenario, this could be the Lambda which you can define in your project (and commit to a repo for version tracking with git). It uses the AWS SNS client library and the SSM client library to communicate with Parameter Store to retrieve a list of stored emails and create an SNS subscription to a topic for them. This example will focus on the code to define and add the Custom Resource with the Lambda and assumes the other constructs such as your SNS topic have already been defined and created.

In the Lambda, note that you need to send a pre-determined and required response structure from the Lambda to let CloudFormation/CDK know that the Lambda completed (either successfully or otherwise). This is what the sendResponse  function does. Without it, CloudFormation/CDK will not know that the Lambda is done doing what it needs to do and will time out and Fail the deployment.

// someLambdaFolder/index.js
const { SNSClient, SubscribeCommand } = require("@aws-sdk/client-sns");
const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm");
const https = require("https");
const snsClient = new SNSClient({ region: process.env.AWS_REGION });
const ssmClient = new SSMClient({ region: process.env.AWS_REGION });

function sendResponse(
  event,
  context,
  responseStatus,
  responseData,
  physicalResourceId
) {

  return new Promise((resolve, reject) => {
    const responseBody = JSON.stringify({
      Status: responseStatus,
      Reason: `See CloudWatch Log Stream: ${context.logStreamName}`,
      PhysicalResourceId: physicalResourceId || context.logStreamName,
      StackId: event.StackId,
      RequestId: event.RequestId,
      LogicalResourceId: event.LogicalResourceId,
      Data: responseData,
    });

    const responseUrl = new URL(event.ResponseURL);

    const options = {
      hostname: responseUrl.hostname,
      port: 443,
      path: responseUrl.pathname + responseUrl.search,
      method: "PUT",
      headers: {
        "content-type": "",
        "content-length": Buffer.byteLength(responseBody),
      },
    };

    const req = https.request(options, (res) => {
      console.log("Status code:", res.statusCode);
      resolve();

    });

    req.on("error", (error) => {
      console.error("sendResponse Error:", error);
      reject(error);
    });

    req.write(responseBody);
    req.end();
  });

}

exports.handler = async (event, context) => {
  console.log("Event:", JSON.stringify(event));

  try {
    if (event.RequestType === "Delete") {
      // No action needed on delete
      await sendResponse(event, context, "SUCCESS", {}, PhysicalResourceId);

      return;
    }

    // Passed in via properties on the custom resource

    const { EmailsParamName, TopicArn, PhysicalResourceId } = event.ResourceProperties;
    const ssmResult = await ssmClient.send(
      new GetParameterCommand({ Name: EmailsParamName })
    );

    const emailsRaw = ssmResult.Parameter.Value;

    const emails = emailsRaw
      .split(",")
      .map((e) => e.trim())
      .filter((e) => e);

    console.log("Parsed emails:", emails);

    for (const email of emails) {
      await snsClient.send(
        new SubscribeCommand({
          Protocol: "email",
          TopicArn: TopicArn,
          Endpoint: email,
          ReturnSubscriptionArn: true,
        })
      );

      console.log(`Subscribed ${email} to ${TopicArn}`);
    }
    await sendResponse(event, context, "SUCCESS", {}, PhysicalResourceId);
  } catch (error) {
    console.error(error);
    await sendResponse(event, context, "FAILED", { Error: error.message }, PhysicalResourceId);
  }
};
// someLambdaFolder/package.json

{
  "name": "lambda",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@aws-sdk/client-sns": "^3.810.0",
    "@aws-sdk/client-ssm": "^3.810.0"
  }
}

Now for the CDK implementation, you’d create a Lambda resource, and add permissions required to accomplish the task of communicating with Parameter Store (SSM) and the AWS SNS service:

// projectRoot/lib/stacks/someStack.ts

import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; // Use NodejsFuction construct to get auto-installed npm dependencies
import * as cr from "aws-cdk-lib/custom-resources";
  const subscriptionHandler = new NodejsFunction(
    this,
    "SnsSubscriptionHandlerLambda",
    {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: "handler", // Name of the exported function in index.js
      entry: path.join(lambdaPath, "someLambdaFolder", "index.js"), // point to where you define the lambda and logic in your project
      timeout: cdk.Duration.seconds(300),
      bundling: {
        externalModules: [], // Specifying this as an empty array includes all packages listed in the lambda folder's package.json and installs them when deployed. include a package-lock.json in the lambda folder if you want consistent versions
        minify: true, // Minify the code to reduce bundle size
      },
    }
  );
    // Grant Lambda permission to read SSM param & manage SNS subscriptions
    subscriptionHandler.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ssm:GetParameter", "sns:Subscribe"],
        resources: [ `arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter${cwEmailsParamName}`,
          alarmTopic.topicArn,
        ],
      })
    );

    // Grant CloudFormation permission to invoke the Lambda
    subscriptionHandler.addPermission("AllowCloudFormationInvoke", {
      principal: new iam.ServicePrincipal("cloudformation.amazonaws.com"),
      action: "lambda:InvokeFunction",
      sourceArn: cdk.Stack.of(this).stackId, // Get stackId from the parent stack
    });

Note, that you should generally use the NodejsFunction construct from `aws-cdk-lib/aws-lambda-nodejs` in CDK, which provides options to automatically bundle and install npm dependencies for you. Otherwise, you’d have to npm install them manually before deploying if using another Lambda construct. You would create a package.json file in the same folder as your Lambda definition file which CDK would pick up automatically and the dependencies defined in that file would be installed when deploying the Custom Resource Lambda.

Finally, you need two further elements to complete the Lambda backed Custom Resource implementation: a Provider, and a Custom Resource construct.

const provider = new cr.Provider(this, "SnsSubscriptionProvider", {
      onEventHandler: subscriptionHandler,
    });

    new cdk.CustomResource(this, "SnsSubscriptionResource", {
      serviceToken: provider.serviceToken,      
    // This is what is passed to the Lambda on invocation as arguments:
    properties: {
        EmailsParamName: cwEmailsParamName,
        TopicArn: alarmTopic.topicArn,
        // Ensures that the function is invoked on every deployment:
        PhysicalResourceId: cr.PhysicalResourceId.of(Date.now().toString())
      },
    });

The Provider construct is a wrapper that encapsulates and extends the Lambda resource construct you created so that you can use it as a backing for the Custom Resource (for example, it exposes a service token which is a unique Identifier/ARN for the Lambda which must be provided to identify which Lambda resource is to be invoked).

A key thing to remember is that the Lambda/Custom Resource is not invoked if none of the properties change, even if you update the code in the Lambda index.js (index.ts) file!  For that reason, adding a dynamically changing unique value such as Date.now().toString() or some kind of “Version” property which increments monotonically will ensure a re-invocation of the Lambda on every deployment if you need to act on fresh and changing data.

That’s Custom Resources in a nutshell. I thought it would be a good topic to cover and try to boil down into a brief post in case it helps others get a quick distilled overview of them and when they are useful.