aws-cloudformation / cloudformation-coverage-roadmap

The AWS CloudFormation Public Coverage Roadmap
https://aws.amazon.com/cloudformation/
Creative Commons Attribution Share Alike 4.0 International
1.11k stars 54 forks source link

AWS::CloudFormation::CustomResource - Lambda Backed CustomResource's ResourceProperties converts int and boolean properties to strings #1037

Open fai555 opened 2 years ago

fai555 commented 2 years ago

Name of the resource

AWS::CloudFormation::CustomResource

Resource Name

AWS::CloudFormation::CustomResource

Issue Description

Created a Custom Resource with boolean and int properties. When these properties are accessed in the Lambda function, they are all converted to string.

  S3CustomResource:
    Type: Custom::S3CustomResource
    Properties:
      ServiceToken: !GetAtt AWSLambdaFunction.Arn
      bucket_name: ferdous-sample-bucket-name-5    ## Additional parameter here
      boolean_property: true    ## Additional parameter here
      integer_property: 14    ## Additional parameter here

Expected Behavior

Although the AWS Cloud formation docs although mentions that the ResourceProperties is a JSON Object. And JSON object can contain Integers and Booleans. https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html

Observed Behavior

Conducted a short experiment to verify if the ResourceProperties in event of the custom resources are passed with the correct data type. Used the following Custom Resource configuration

Resources:
  SampleS3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: <GLOBALLY_UNIQUE_BUCKET_NAME>
    DeletionPolicy: Delete

  S3CustomResource:
    Type: Custom::S3CustomResource
    Properties:
      ServiceToken: !GetAtt AWSLambdaFunction.Arn
      bucket_name: <GLOBALLY_UNIQUE_BUCKET_NAME>
      boolean_property: true
      integer_property: 14

  AWSLambdaFunction:
     Type: AWS::Lambda::Function
     Properties:
       Description: "Empty an S3 bucket!"
       FunctionName: !Sub '${AWS::StackName}-${AWS::Region}-lambda'
       Handler: index.handler
       Role: !GetAtt AWSLambdaExecutionRole.Arn
       Timeout: 360
       Runtime: python3.8
       Code:
         ZipFile: |
          import boto3
          import cfnresponse

          def handler(event, context):
              # Get request type
              the_event = event['RequestType']        
              print("The event is: ", str(the_event))

              response_data = {}
              s3 = boto3.client('s3')

              # Retrieve parameters (bucket name)
              bucket_name = event['ResourceProperties']['bucket_name']

              response_data["bucket_name"] = str(type(event['ResourceProperties']['bucket_name']))
              response_data["boolean_property"] = str(type(event['ResourceProperties']['boolean_property']))
              response_data["integer_property"] = str(type(event['ResourceProperties']['integer_property']))

              try:
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)

              except Exception as e:
                  print("Execution failed...")
                  print(str(e))
                  response_data['Data'] = str(e)
                  cfnresponse.send(event, context, cfnresponse.FAILED, response_data)

  AWSLambdaExecutionRole:
     Type: AWS::IAM::Role
     Properties:
       AssumeRolePolicyDocument:
         Statement:
         - Action:
           - sts:AssumeRole
           Effect: Allow
           Principal:
             Service:
             - lambda.amazonaws.com
         Version: '2012-10-17'
       Path: "/"
       Policies:
       - PolicyDocument:
           Statement:
           - Action:
             - logs:CreateLogGroup
             - logs:CreateLogStream
             - logs:PutLogEvents
             Effect: Allow
             Resource: arn:aws:logs:*:*:*
           Version: '2012-10-17'
         PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-CW
       - PolicyDocument:
           Statement:
           - Action:
             - s3:PutObject
             - s3:DeleteObject
             - s3:List*
             Effect: Allow
             Resource:
             - !Sub arn:aws:s3:::${SampleS3Bucket}
             - !Sub arn:aws:s3:::${SampleS3Bucket}/*
           Version: '2012-10-17'
         PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-S3
       RoleName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambdaExecutionRole

After applying it, the following logs appear

{
    "Status": "SUCCESS",
    "Reason": "See the details in CloudWatch Log Stream: ...",
    "PhysicalResourceId": "...",
    "StackId": "...",
    "RequestId": "4fe1e63e-dc02-4087-a1e8-541efe0ad011",
    "LogicalResourceId": "S3CustomResource",
    "NoEcho": false,
    "Data": {
        "bucket_name": "<class 'str'>",
        "boolean_property": "<class 'str'>",
        "integer_property": "<class 'str'>"
    }
}

The properties are always returning String even though they have been passed as String, Boolean and Integer.

Test Cases

Pass any integer or boolean key value pair in the Properties section and you can see that the properties received in the event['ResourceProperties'] object are all in string format.

  S3CustomResource:
    Type: Custom::S3CustomResource
    Properties:
      ServiceToken: !GetAtt AWSLambdaFunction.Arn
      bucket_name: <GLOBALLY_UNIQUE_BUCKET_NAME>
      boolean_property: true
      integer_property: 14

Other Details

Conducted a short experiment to verify if the properties of the custom resources are passed with the correct data type if the Custom Resource is applied from local machine, instead of using AILM. Used the following Custom Resource configuration in a test account to verify that.

Resources:
  SampleS3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: ferdous-sample-bucket-name-5
    DeletionPolicy: Delete

  S3CustomResource:
    Type: Custom::S3CustomResource
    Properties:
      ServiceToken: !GetAtt AWSLambdaFunction.Arn
      bucket_name: ferdous-sample-bucket-name-5    ## Additional parameter here
      boolean_property: true    ## Additional parameter here
      integer_property: 14    ## Additional parameter here

  AWSLambdaFunction:
     Type: AWS::Lambda::Function
     Properties:
       Description: "Empty an S3 bucket!"
       FunctionName: !Sub '${AWS::StackName}-${AWS::Region}-lambda'
       Handler: index.handler
       Role: !GetAtt AWSLambdaExecutionRole.Arn
       Timeout: 360
       Runtime: python3.8
       Code:
         ZipFile: |
          import boto3
          import cfnresponse

          def handler(event, context):
              # Get request type
              the_event = event['RequestType']        
              print("The event is: ", str(the_event))

              response_data = {}
              s3 = boto3.client('s3')

              # Retrieve parameters (bucket name)
              bucket_name = event['ResourceProperties']['bucket_name']

              response_data["bucket_name"] = str(type(event['ResourceProperties']['bucket_name']))
              response_data["boolean_property"] = str(type(event['ResourceProperties']['boolean_property']))
              response_data["integer_property"] = str(type(event['ResourceProperties']['integer_property']))

              try:
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)

              except Exception as e:
                  print("Execution failed...")
                  print(str(e))
                  response_data['Data'] = str(e)
                  cfnresponse.send(event, context, cfnresponse.FAILED, response_data)

  AWSLambdaExecutionRole:
     Type: AWS::IAM::Role
     Properties:
       AssumeRolePolicyDocument:
         Statement:
         - Action:
           - sts:AssumeRole
           Effect: Allow
           Principal:
             Service:
             - lambda.amazonaws.com
         Version: '2012-10-17'
       Path: "/"
       Policies:
       - PolicyDocument:
           Statement:
           - Action:
             - logs:CreateLogGroup
             - logs:CreateLogStream
             - logs:PutLogEvents
             Effect: Allow
             Resource: arn:aws:logs:*:*:*
           Version: '2012-10-17'
         PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-CW
       - PolicyDocument:
           Statement:
           - Action:
             - s3:PutObject
             - s3:DeleteObject
             - s3:List*
             Effect: Allow
             Resource:
             - !Sub arn:aws:s3:::${SampleS3Bucket}
             - !Sub arn:aws:s3:::${SampleS3Bucket}/*
           Version: '2012-10-17'
         PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-S3
       RoleName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambdaExecutionRole

After applying it from a local setup to the testaccount-ferdous, the following logs appear

{
    "Status": "SUCCESS",
    "Reason": "See the details in CloudWatch Log Stream: 2022/01/18/[$LATEST]b02a0c9fdb9a4737b2c37f8752eb5590",
    "PhysicalResourceId": "2022/01/18/[$LATEST]b02a0c9fdb9a4737b2c37f8752eb5590",
    "StackId": "arn:aws:cloudformation:eu-west-1:438171217252:stack/custom-resource-test-5/a0404ef0-783d-11ec-99aa-06a8de4b0d85",
    "RequestId": "4fe1e63e-dc02-4087-a1e8-541efe0ad011",
    "LogicalResourceId": "S3CustomResource",
    "NoEcho": false,
    "Data": {
        "bucket_name": "<class 'str'>",
        "boolean_property": "<class 'str'>",
        "integer_property": "<class 'str'>"
    }
}

The properties are always returning String even though they have been passed as String, Boolean and Integer.

  S3CustomResource:
    Type: Custom::S3CustomResource
    Properties:
      ServiceToken: !GetAtt AWSLambdaFunction.Arn
      bucket_name: ferdous-sample-bucket-name-5    ## Additional parameter here
      boolean_property: true    ## Additional parameter here
      integer_property: 14    ## Additional parameter here

Although the AWS Cloud formation docs although mentions that the ResourceProperties is a JSON Object. And JSON object can contain Integers and Booleans. https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html

The next course of action is to look into the issues of CloudFormation to see if it's already reported. If not, then create an issue and follow up on that. And for the time being, keep using the manual type mapping that we have been doing in the CustomResource Lambda Functions.

ilons commented 1 year ago

I've just spent a decent amount of time figuring out this bug, as there is no mention of this in the documentation that I could see. At a minimum, add a disclaimer for this in the docs.

mrpackethead commented 1 year ago

This plauged me for a while too. :-) I worked around it, by passing things in as a base64 encrypted string

const coreNetworkCfg = new cdk.CustomResource(this, 'UpdateCoreNetworkConfiguration', { serviceToken: policyTable.serviceToken, properties: { policyTableName: policyTable.policyTable.tableName, coreNetworkConfiguration: cdk.Fn.base64(cdk.Stack.of(this).toJsonString(coreNetworkConfiguration)), coreName: props.coreName, }, });

Then in my python lambda, sucking them back out. 

`coreNetworkConfiguration = json.loads(base64.b64decode(props['coreNetworkConfiguration']).decode("utf-8"))`
ilons commented 1 year ago

This plauged me for a while too. :-) I worked around it, by passing things in as a base64 encrypted string

const coreNetworkCfg = new cdk.CustomResource(this, 'UpdateCoreNetworkConfiguration', { serviceToken: policyTable.serviceToken, properties: { policyTableName: policyTable.policyTable.tableName, coreNetworkConfiguration: cdk.Fn.base64(cdk.Stack.of(this).toJsonString(coreNetworkConfiguration)), coreName: props.coreName, }, });

Then in my python lambda, sucking them back out. 

`coreNetworkConfiguration = json.loads(base64.b64decode(props['coreNetworkConfiguration']).decode("utf-8"))`

This is what I ended up doing as well, works fairly well, but feel a bit odd, and make it harder to see actual changes to the data.

markilott commented 1 year ago

Also came across this while troubleshooting the issue in CDK projects.

Converting the data object to string using JSON.stringify(data) in the stack works. Then just use JSON.parse(dataStr) in the custom resource function to decode.

alexisfacques commented 1 year ago

+1. This can be very limiting for custom resources that implement some AWS/Boto APIs that have very strict typing validation. Any workaround do feel a little hacky.

matthewpick commented 1 year ago

Found this same issue with the Python CDK. For instance, when passing a dictionary into CustomResource(..., properties={"key": 1}), the integer value will be converted to a string.

pbudzon commented 6 months ago

This also comes up when using boto3 macro

For example, since CloudFormation STILL doesn't support CustomizedMetricSpecification in AWS::AutoScaling::ScalingPolicy (https://github.com/aws-cloudformation/cloudformation-coverage-roadmap/issues/1495), you may want to use boto3 macro to create the policy - especially since it's a simple put_scaling_policy call in boto. That will fail, however, because bools and ints get converted to strings by the custom resource call which fails validation.

Possible workaround, when using YAML with CloudFormation is:

  1. Define the Properties as a json string:

    TargetScalingPolicy:
    Type: Boto3::autoscaling.put_scaling_policy
    Mode: [ Create, Update ]
    Properties: !Sub | 
      { 
        "AutoScalingGroupName": "${AutoScalingGroup}",
        "PolicyName": "${AWS::StackName}",
        "PolicyType": "TargetTrackingScaling",
        "EstimatedInstanceWarmup": 240,
    (...)
  2. Modify the boto3 macro resource lambda around line 82, so that this whole if looks like this:

    if request == mode or request in mode:
        if isinstance(properties["Properties"], str):
            properties["Properties"] = json.loads(properties["Properties"])
        status, message = execute(properties["Action"], properties["Properties"])
        return sendResponse(event, context, status, message)

    (we add the if isinstance(properties["Properties"], str): ... part) to convert the properties back into an object.

Obvious disadvantage is that now you have a JSON string inside your YAML template, which reduced readability and since it's a string most editors/validators will ignore it, so you don't have any validation of what you put in there - easy to make a mistake.

Kavinraja-G commented 3 months ago

Also came across this while troubleshooting the issue in CDK projects.

Converting the data object to string using JSON.stringify(data) in the stack works. Then just use JSON.parse(dataStr) in the custom resource function to decode.

@markilott You meant the entire Data from the CR or just one of the attr in Data. For me it just errors when trying to parse a attribute from Data during synth.

my_stringified_data = my_cr.get_att("stringified_json_object").to_string()

# Parsing the above data for using with another construct
json.loads(my_stringified_data)

My understanding is we can't manipulate the response, since it's not available during synth operation.