Implementing “for each” logic in the CloudFormation template for dynamic input parameters.

Table of Contents

Problem statement

In some cases, CloudFormation’s capabilities may be limited compared with those of other IaC tools, such as Terraform, CDK, Pulumi, etc. For example, in the previous post, we looked at implementing a “sleep” timeout using the Custom CloudFormation resource.

Here, we look at the case where we must create a Route 53 Resolver Rule. The input parameter TargetIps expects a list of strings, but we should get it from the AWS Systems Manager Parameter Store dynamically. So, we have to be able to provide as many IP addresses as we need in that list and get the stack updated.

It does not matter which resource we create via CloudFormation; this post aims to show how we can dynamically generate input parameters.

Solution overview

Here is an example of the Route53 resolver rule definition in CloudFormation:

Type: AWS::Route53Resolver::ResolverRule
Properties: 
  DomainName: example.com
  Name: MyRule
  ResolverEndpointId: rslvr-out-fdc049932dexample
  RuleType: FORWARD 
  TargetIps:
    - 
      Ip: 192.0.2.6
      Port: 53
    -  
      Ip: 192.0.2.99
      Port: 53

But we don’t want to add values to the list or remove them if the number of IPs changes over time. TargetIps expects an array of dictionaries. So, the ideal situation would look like this:

 

Type: AWS::Route53Resolver::ResolverRule
Properties: 
  DomainName: example.com
  Name: MyRule
  ResolverEndpointId: rslvr-out-fdc049932dexample
  RuleType: FORWARD 
  TargetIps: <Get an array here by reference to SSM Parameter>

The parameter store has a StringList type of parameter, where we store two IP addresses:

The native CloudFormation capability can reference the SSM parameter, but using this, we would have something like this:

 

TargetIps: 
        - Ip: !Select [0, !Split [",", '{{resolve:ssm:parameter-name:version}}']]
          Port: 53
        - Ip: !Select [1, !Split [",", '{{resolve:ssm:parameter-name:version}}']]
          Port: 53

This does not look good and does not allow adding/removing items to the StringList parameter without modification of the CloudFormation template.

The solution is the custom CloudFormation resource, which uses the Lambda function in the background. The Lambda function can perform any logic. We need to provide a correct Input to the Lambda and make the Lambda return the correct Output for the CloudFormation.

The following sample data shows what AWS CloudFormation includes in a request:
{
   "RequestType" : "Create",
   "ResponseURL" : "http://pre-signed-S3-url-for-response",
   "StackId" : "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid",
   "RequestId" : "unique id for this create request",
   "ResourceType" : "Custom::TestResource",
   "LogicalResourceId" : "MyTestResource",
   "ResourceProperties" : {
      "Name" : "Value",
      "List" : [ "1", "2", "3" ]
   }
}

The following sample data shows what a custom resource might include in a response:

 

{
   "Status" : "SUCCESS",
   "PhysicalResourceId" : "TestResource1",
   "StackId" : "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid",
   "RequestId" : "unique id for this create request",
   "LogicalResourceId" : "MyTestResource",
   "Data" : {
      "OutputName1" : "Value1",
      "OutputName2" : "Value2",
   }
}

Here is an example of the CloudFormation template:

 

Parameters:
  ParameterPath:
    Type: String
    Default: /core/bmas-ip-address
    Description: Parameter path containing comma-separated list of IP addresses

Resources:
  CustomResolverRuleUpdaterFunction:
    Type: 'AWS::Lambda::Function'
    Properties:
      Code:
        ZipFile: |
          import json
          import logging
          import signal
          import urllib3
          import boto3

          LOGGER = logging.getLogger()
          LOGGER.setLevel(logging.INFO)

          def handler(event, context):
              try:
                  LOGGER.info('REQUEST RECEIVED:\n %s', event)
                  LOGGER.info('REQUEST RECEIVED:\n %s', context)
                  if event['RequestType'] == 'Create':
                      LOGGER.info('CREATE!')

                    # Your custom logic is here

                      ssm_parameter_path = event['ResourceProperties']['ParameterPath']
                      ssm_client = boto3.client('ssm')
                      response = ssm_client.get_parameter(Name=ssm_parameter_path, WithDecryption=True)
                      ip_list = response['Parameter']['Value'].split(',')
                        
                      response_data = {
                          'TargetIps': [{'Ip': ip, 'Port': "53"} for ip in ip_list]
                      }    

                      send_response(event, context, "SUCCESS", response_data)


                  elif event['RequestType'] == 'Update':
                      LOGGER.info('UPDATE!')
                      send_response(event, context, "SUCCESS",
                                    {"Message": "Resource update successful!"})
                  elif event['RequestType'] == 'Delete':
                      LOGGER.info('DELETE!')
                      send_response(event, context, "SUCCESS",
                                    {"Message": "Resource deletion successful!"})
                  else:
                      LOGGER.info('FAILED!')
                      send_response(event, context, "FAILED",
                                    {"Message": "Unexpected event received from CloudFormation"})
              except Exception as e:
                  LOGGER.info('FAILED!')
                  send_response(event, context, "FAILED", {"Message": "Exception during processing: {}".format(str(e))})


          def send_response(event, context, response_status, response_data):
              '''Send a resource manipulation status response to CloudFormation'''
              response_body = json.dumps({
                  "Status": response_status,
                  "Reason": "See the details in CloudWatch Log Stream: " + context.log_stream_name,
                  "PhysicalResourceId": context.log_stream_name,
                  "StackId": event['StackId'],
                  "RequestId": event['RequestId'],
                  "LogicalResourceId": event['LogicalResourceId'],
                  "Data": response_data
              })

              LOGGER.info('ResponseURL: %s', event['ResponseURL'])
              LOGGER.info('ResponseBody: %s', response_body)

              http = urllib3.PoolManager()
              response = http.request('PUT', event['ResponseURL'], body=response_body, headers={'Content-Type': ''})
              LOGGER.info("Status code: %s", response.status)
              LOGGER.info("Status message: %s", response.reason)

          def timeout_handler(_signal, _frame):
              '''Handle SIGALRM'''
              raise Exception('Time exceeded')

          signal.signal(signal.SIGALRM, timeout_handler)

      Handler: index.handler
      Role: !GetAtt CustomResolverRuleUpdaterRole.Arn
      Runtime: python3.8

  CustomResolverRuleUpdaterRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: 'sts:AssumeRole'
      Policies:
        - PolicyName: CustomResolverRuleTargetIpPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - 'ssm:GetParameter'
                Resource: '*'
        - PolicyName: WriteLogs
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - 'logs:*'
                Resource: 'arn:aws:logs:*:*:*'

  CustomResolverRuleUpdater:
    Type: 'Custom::ResolverRuleUpdater'
    Properties:
      ServiceToken: !GetAtt CustomResolverRuleUpdaterFunction.Arn
      ParameterPath: !Ref ParameterPath

  MyResolverRule:
    Type: 'AWS::Route53Resolver::ResolverRule'
    Properties:
      DomainName: ait-demo.com
      RuleType: FORWARD
      ResolverEndpointId: rslvr-out-cea2766ca6ca474ca
      TargetIps: !GetAtt CustomResolverRuleUpdater.TargetIps

Ideally, you must define all RequestTypes (Create, Update, Delete). In this example, I show only the “Create” part. Such a Lambda function will be executed during the CloudFormation stack Create/Update/Delete events, so it should know what to do and return the necessary signal back to the CloudFormation service.

The stack creates an IAM role for the Lambda function, Lambda function itself calls this Lambda and gets its output, using this output as an Input for Route53 Resolver Rule:

Logs of the Lambda function:

The resolver rule has been created with two IP addresses from the SSM parameter:

Conclusion

In this post, I demonstrated how we can use Lambda-backed custom CloudFormation resources to implement “for each” logic that is not currently available as a native CloudFormation capability. Using such an approach, we can implement a lot of logic. In addition, we can implement communication with 3rd party services and APIs and incorporate it into the infrastructure as a code automation template.