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.
{ "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.