typescriptamazon-web-servicesaws-cdkaws-parameter-store

How to reference JSON values from a parameter store using a AwsCustomResource in AWS CDK?


I have been using the solution proposed here (https://stackoverflow.com/a/59774628/91403) in order to access configurations stored in an AWS parameter.

export class SSMParameterReader extends AwsCustomResource {
  constructor(scope: Construct, name: string, props: SSMParameterReaderProps) {
    const { parameterName, region } = props;

    const ssmAwsSdkCall: AwsSdkCall = {
      service: 'SSM',
      action: 'getParameter',
      parameters: {
        Name: parameterName
      },
      region,
      physicalResourceId: {id:Date.now().toString()}
    };

    super(scope, name, { onUpdate: ssmAwsSdkCall,policy:{
        statements:[new iam.PolicyStatement({
        resources : ['*'],
        actions   : ['ssm:GetParameter'],
        effect:iam.Effect.ALLOW,
      }
      )]
   }});
  }

  public getParameterValue(): string {
    return this.getResponseField('Parameter.Value').toString();
  }
}

Now I came across a parameter in the JSON format and have found no way to access its value. I can't seem to be able to parse it. Here is one example: { "subnetId": "subnet-xxxxxx" }.

I have tried modifying the code in several ways but mainly in the likes of:


  ...

  public getParameterValue(path: string): string {
    return this.getResponseField(`Parameter.Value.${path}`).toString();
  }

How can I extract the subnetId value?


Solution

  • Using a lambda-backed custom resource (CR), you can fetch the parameter and parse the JSON string within the custom resource handler itself. The solution below supports (1) optional cross-region parameters and (2) an arbitrary number of keys in the stringified parameter.

    Step 1: Define a custom resource

    For convenience, I encapsulate the custom resource, the provider, and the lambda handler in a construct wrapper. Pass the SSM parameter name and optionally a region as props. The default region is the stack's region.

    // GetJsonParamCR.ts
    
    export interface GetJsonParamCRProps {
      parameterName: string;
      region?: string;
    }
    
    export class GetJsonParamCR extends Construct {
      readonly customResource: CustomResource;
    
      constructor(scope: Construct, id: string, props: GetJsonParamCRProps) {
        super(scope, id);
    
        const func = new nodejs.NodejsFunction(this, "GetJsonParamLambda", {
          entry: path.join(__dirname, "index.ts"),
          runtime: lambda.Runtime.NODEJS_18_X,
          bundling: { externalModules: ["@aws-sdk/client-ssm"] },
        });
    
        const paramArn = Stack.of(this).formatArn({
          region: props.region,
          service: "ssm",
          resource: "parameter",
          resourceName: props.parameterName,
          arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
        });
    
        func.addToRolePolicy(
          new iam.PolicyStatement({
            actions: ["ssm:GetParameter"],
            resources: [paramArn],
          })
        );
    
        const provider = new cr.Provider(this, "ProviderCR", {
          onEventHandler: func,
        });
    
        this.customResource = new CustomResource(this, "Resource", {
          resourceType: "Custom::GetJsonParam",
          serviceToken: provider.serviceToken,
          properties: props,
        });
      }
    }
    

    Step 2: Define the CR lambda hander

    The Lambda code first gets the parameter with the JS SDK v3 (preloaded with 18.x Lambdas). Then it parses the parameter string and returns the key-value pairs. I am omitting error handling for simplicity.

    // index.ts
    
    export const handler = async (
      event: Omit<lambda.CdkCustomResourceEvent, "ResourceProperties"> & {
        ResourceProperties: GetJsonParamCRProps & { ServiceToken: string };
      }
    ): Promise<lambda.CdkCustomResourceResponse> => {
      if (event.RequestType === "Delete") return {};
    
      const { parameterName, region } = event.ResourceProperties;
    
      const client = new SSMClient({ region: region ?? process.env.AWS_REGION });
    
      const cmd = new GetParameterCommand({ Name: parameterName });
    
      const res = await client.send(cmd);
      const parsed = JSON.parse(res.Parameter?.Value ?? "{}");
    
      return { Data: parsed };
    };
    

    Step 3: Add the CR to a stack

    Instantiate the CR in a stack. The CfnOutput is added to illustate how to consume the value. Upon deployment, the output value will print to the console: MyStackDD013518.SubnetId = subnet-xxxxxx.

    Note that this solution supports a JSON parameter with multiple keys (e.g. r.customResource.getAttString("anotherKey")).

    // MyStack.ts
    
    export class GetJsonParamStack extends cdk.Stack {
      constructor(scope: Construct, id: string, props: cdk.StackProps) {
        super(scope, id, props);
    
        const r = new GetJsonParamCR(this, "GetJsonParamCR", {
          parameterName: "myJsonParam",
          region: "eu-central-1",
        });
    
        new cdk.CfnOutput(this, "SubnetId", {
          value: r.customResource.getAttString("subnetId"),
        });
      }
    }
    

    Alternative solution: no Custom Resource, no cross-region support

    OP explicitly requires use of a custom resource and implicitly needs to support cross-region parameters. If these two requirements are relaxed, the solution is much simpler. Use the StringParameter.valueFromLookup static method. valueFromLookup is a so-called context method, which makes the GetParameter SDK call at synth-time (on the first run), and caches the result. Because the resolved parameter value is available at synth-time, you can parse it directly within your CDK code.

    As above, a CfnOutput value is added for illustration. It returns subnet-xxxxxx.

    export class NoCustomResourceStack extends cdk.Stack {
      constructor(scope: Construct, id: string, props: cdk.StackProps) {
        super(scope, id, props);
    
        const param = ssm.StringParameter.valueFromLookup(this, "myJsonParam");
        // this ternary check handles the dummy context value the CDK sets internally on first run
        const subnetId = param.startsWith("dummy-value") ? "dummy" : JSON.parse(param)?.subnetId;
    
        new cdk.CfnOutput(this, "SubnetId", {
          value: subnetId,
        });
      }
    }