AWS Cloud Operations & Migrations Blog

Four ways to retrieve any AWS service property using AWS CloudFormation (Part 2 of 3)

This post is the second in a series on how to build customizations using AWS CloudFormation. In part 1, we showed you how to develop customizations using cfn-response and crhelper and shared the scenarios they are best suited for. In this post, we’ll use AWS CloudFormation macros to address some of the coverage gaps identified in our public roadmap.

Prerequisites

To complete the example presented here, we use YAML, AWS Lambda, and Python. We also use the AWS Serverless Application Model (AWS SAM), a built-in transform hosted by AWS CloudFormation that provides shorthand syntax to express functions, APIs, and other resources.

Option 3: Custom resource using AWS CloudFormation macros

About this blog post
Time to read 15 minutes
Time to complete ~ 20 minutes
Learning level Advanced (300)
AWS Services AWS CloudFormation
AWS SAM
AWS Lambda
Amazon Elastic Compute Cloud (Amazon EC2)
Software Tools AWS CLI version 2
Linux, macOS, or Windows subsystem for Linux

In part 1, we addressed the request in GitHub issue 157 to retrieve the Amazon EC2 security group name with the cfn-response module. In this post, we’ll use macros to fetch the same security group name. There are about 70 similar issues as of the time of this writing in our public coverage roadmap, collecting more than 168 reactions from the community.

Macros are AWS Lambda-backed functions that you register in AWS CloudFormation to perform code transformations, with operations like string manipulation, find and replace, loops, or other substitutions. Macros are invoked using the Transform keyword in your template. Once the template is uploaded to AWS CloudFormation, and the code parser encounters the Transform keyword, the AWS Lambda function containing your transformation logic is executed . Your macro will be processed after you submit your template for processing, but before starting the CREATE, UPDATE, and DELETE stack operations.

After your macros are deployed, template users aren’t distracted by the macros’ code, so you get the same flexibility in adding custom logic as you did before, while abstracting the details away. Because macros are not bound to a specific template, you can reuse them across many stacks in your account, much like crhelper. In contrast to other templating languages like Jinja, macros are built into the AWS CloudFormation backend. You can use any language Lambda functions support, including those enabled by Lambda layers.

We author AWS CloudFormation templates to define the macro and its Python code. Because we are using AWS SAM, we need to organize our code and templates in a specific way. We create the following directory structure:

$ tree option3
option3
├── cfn-ec2-custom-resource.yml
├── cfn-macro.yml
└── lambda_cr_function
         ├── crhelper
         ├── crhelper-2.0.6.dist-info
         ├── tests
         ├── macro.py
         └── resource.py

Let’s look at how macros are invoked in templates. Pay close attention to the following sample template, because it hints at how we’ll use a new macro to retrieve the attribute we are missing:

cfn-ec2-custom-resource.yml

AWSTemplateFormatVersion: '2010-09-09'

Transform: SGMacro

Parameters:
  vpcID:
    Type: AWS::EC2::VPC::Id
    Description: Enter VPC Id
    
Resources:
  CfnEC2SecurityGroup:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: CFN2 Security Group Description
      VpcId: !Ref vpcID
  
  CustomSecurityGroupNameMacro:
    Type: SGMacro    
    Properties:
      RefId: !Ref 'CfnEC2SecurityGroup'
      
Outputs:
  SecurityGroupID:
    Description: Security Group ID
    Value: !Ref CfnEC2SecurityGroup
  SecurityGroupName:
    Description: Security Group Name from Macro
    Value: !GetAtt 'CustomSecurityGroupNameMacro.SecurityGroup-Name'

What the template does

We use the Transform keyword to signal to AWS CloudFormation that we’re using a macro named SGMacro. This keyword is at the same global level as the Parameters, Resources, and Outputs keywords. We use Parameters to capture the VPC ID, making this template reusable. In the required Resources section, we create an AWS::EC2::SecurityGroup in the VPC.

Next, we invoke SGMacro to get the missing EC2 security group name. There are two ways to reference a macro in your template. In our example, we use the Transform keyword at the top of the file to allow SGMacro to process the entire template content and to invoke it multiple times, if necessary. If you want to scope down the template content that a macro can transform, use the Fn:Transform intrinsic function inside any resource definition. You can use both conventions in the same template for different macros. If you do, see the evaluation order logic in the AWS CloudFormation User Guide.

We have defined our SGMacro to expect an EC2 security group ID as a parameter and to return the EC2 security group name when executed. This is one of the big advantages to using macros: end users can invoke them much like they declare resources, without worrying about your function code.

Using the !Ref and !GetAtt intrinsic functions, we display the EC2 security group ID and name in the Outputs section.

Now that we know what the structure and syntax of our template looks like, let’s build the parts that will enable us to use it in its simplified format.

Create macros with AWS SAM

To create a macro, we must first define and then implement it. We use the open source AWS SAM framework.

cfn-macro.yml

AWSTemplateFormatVersion: '2010-09-09'

Transform: AWS::Serverless-2016-10-31

Resources:
  ResourceFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.8
      CodeUri: lambda_cr_function
      Handler: resource.handler
      Timeout: 30
      Policies:
      - Statement:
        - Sid: CloudWatchLogPolicy
          Effect: Allow
          Action:
          - logs:CreateLogGroup
          - logs:CreateLogStream
          - logs:PutLogEvents
          Resource: '*'
        - Sid: EC2DescribePolicy
          Effect: Allow
          Action:
          - ec2:DescribeSecurityGroups
          Resource: '*'

  MacroFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.8
      CodeUri: lambda_cr_function
      Handler: macro.handler
      Environment:
        Variables:
          LAMBDA_ARN: !GetAtt ResourceFunction.Arn

  Macro:
    Type: AWS::CloudFormation::Macro
    Properties:
      Name: SGMacro
      FunctionName: !GetAtt MacroFunction.Arn 

What the template does

First, using the shorthand syntax we mentioned earlier, we signal to AWS CloudFormation that our code requires the AWS::Serverless transform to create two required functions. The syntax is similar to the AWS::Lambda::Function type, but it simplifies the definition of the function and uploads all required artifacts to Amazon Simple Storage Service (Amazon S3) before deployment.

In the Resources section, we start by defining ResourceFunction, an AWS Lambda function to get the missing EC2 security group name attribute. This resource is configured with security controls to allow only ec2:DescribeSecurityGroups API actions and to log data to Amazon CloudWatch.

The use of AWS SAM here is deliberate, because it shows the power of using multiple macros to create clear, concise code while enabling customization. Because we used AWS SAM, we don’t need to explicitly define AWS IAM roles and policies separately in our code, not even the S3 bucket we use behind the scenes! This is why AWS SAM is so popular. Learning macros can empower you to add similar coding efficiencies for both developers and end users. Aren’t you glad you are reading this?

Taking advantage of the same AWS SAM automations, we implement the wrapper by defining it as another AWS Lambda function, MacroFunction. This function expects the entire AWS CloudFormation template as a JSON payload request. MacroFunction then parses and transforms the payload.

Our last resource is where we declare the macro using AWS::CloudFormation::Macro, which allows it to be used in other templates. The name of the macro must be a unique resource identifier in the Region and account that implements it. When our SGMacro is invoked, it also expects the entire AWS CloudFormation template passed in as a JSON payload. For an in-depth macro workflow, see the Extending AWS CloudFormation with AWS Lambda Powered Macros blog post.

Functions

This next section is exactly the same as outlined in part 1 of this series, but to make each of these options stand alone, we repeat it here for your convenience. We first install the crhelper Python module like we did before, and then we create the AWS Lambda function.

mkdir lambda_cr_function
cd lambda_cr_function
#install crhelper in current directory using --target flag
pip install --target . crhelper

resource.py

from __future__ import print_function
from crhelper import CfnResource
import boto3
import logging
logger = logging.getLogger()
helper = CfnResource(json_logging=False, log_level='DEBUG', boto_level='CRITICAL')
@helper.create
@helper.update
def create(event, context):
    # 1. retrieve resource reference ID or Name
    ResourceRef = event['ResourceProperties']['ResourceRef']    
    # 2. retrieve boto3 client    
    client = boto3.client('ec2')
    # 3. Invoke describe/retrieve function using ResourceRef
    response = client.describe_security_groups(GroupIds=[ResourceRef['RefId']])
    # 4. Parse and return required attributes 
    helper.Data['SecurityGroup-Name'] = response.get('SecurityGroups')[0].get('GroupName')
    
@helper.delete
def no_op(_, __):
    pass
def handler(event, context):
    helper(event, context)

As you might recall from part 1, crhelper handles timeouts, provides detailed logging, and exposes prebuilt decorators for CREATE, UPDATE, and DELETE requests. The only difference between this code fragment and the one used in part 1 is the use of the describe_security_groups EC2 API action, rather than the describe_file_systems Amazon FSx for Windows File Server API action.

Next, we link the ResourceFunction and MacroFunction together. Our goal is to invoke the ResourceFunction, which has the customization we just built.

Here’s our final piece of code:

macro.py

import os

PREFIX = "SGMacro"

LAMBDA_ARN = os.environ["LAMBDA_ARN"]

def handle_template(request_id, template):
    for name, resource in template.get("Resources", {}).items():
        if resource["Type"].startswith(PREFIX):
            customResponse = resource.update({
                "Type": "AWS::CloudFormation::CustomResource",
                "Properties": {
                    "ServiceToken": LAMBDA_ARN,
                    "ResourceRef": resource.get("Properties", {}),
                },
            })

    return template

def handler(event, context):
    fragment = event["fragment"]
    status = "success"
    try:
        fragment = handle_template(event["requestId"], event["fragment"])
    except Exception as e:
        status = "failure"
    return {
        "requestId": event["requestId"],
        "status": status,
        "fragment": fragment,
    }

During stack creation, MacroFunction returns a JSON-formatted response with requestId, status, and fragment. The fragment contains the transformed template. The following table shows the original and processed template, where SGMacro is replaced by the custom resource function definition, hiding the underlying implementation details from end users.

Original template CustomSecurityGroupNameMacro:
Type: SGMacro
Properties:
RefId: !Ref ‘CfnEC2SecurityGroup’
Processed template CustomSecurityGroupNameMacro:
Type: AWS::CloudFormation::CustomResource
Properties:
ServiceToken: “arn:aws:lambda:us-east-1:123456789012:function:simple-sg-macro-ResourceFunction-1R2QVKS104F8R”
ResourceRef:
RefId: !Ref ‘CfnEC2SecurityGroup’

You can view both the original and processed templates in the console. Using the Template tab on the Stack Details page, you can toggle between code listings, as shown in Figure 1.

On the Stack Details page, the toggle option to view the processed securitygroup-retriever-example template is selected.

Figure 1: Using the Template tab on the Stack Details page

Now that you have seen all the code, it’s time to deploy our macro!

  1.  Save the template files under the option3 folder and the Python files under the lambda_cr_function folder.
  2. Create a unique S3 bucket to store the AWS CloudFormation artifacts.
  3. Use the AWS CLI to run the package command, which packages and uploads local artifacts to the S3 bucket you just created. This command returns a template named cfn-packaged-macro.yml and replaces all references to local artifacts with the S3 bucket location.
  4. Use the AWS CLI to run the deploy command.
# Run below commands from the option3 folder
aws s3 mb s3://unique-s3-bucket
aws cloudformation package --template-file cfn-macro.yml --s3-bucket unique-s3-bucket --output-template-file cfn-packaged-macro.yml
aws cloudformation deploy --template-file cfn-packaged-macro.yml --capabilities CAPABILITY_IAM --stack-name simple-sg-macro

Figure 2 shows the successful creation of resources related to SGMacro. This macro is now available to use in any stack in the account and region it was deployed at.

The Stack Details page displays columns for logical ID, physical ID, type, and status for Macro, MacroFunction, MacroFunctionRole, ResourceFunction, and ResourceFunctionRole.

Figure 2: Resources tab on the Stack Details page of the AWS CloudFormation console

Test the macro

Let’s put our macro to the test by using the cfn-ec2-custom-resource.yml template to deploy an AWS CloudFormation stack. The stack creates the EC2 security group and uses SGMacro to retrieve its name.

#deploy the stack
aws cloudformation deploy --stack-name securitygroup-retriever-example --template-file cfn-ec2-custom-resource.yml --parameter-overrides vpcID=vpc_id_in_your_account

#validate stack output 
aws cloudformation describe-stacks --stack-name securitygroup-retriever-example --query "Stacks[0].Outputs"
[
    {
        "OutputKey": "SecurityGroupName",
        "OutputValue": "securitygroup-retriever-example-CfnEC2SecurityGroup-ABCDE1234512",
        "Description": "Security Group Name from Macro"
    },
    {
        "OutputKey": "SecurityGroupID",
        "OutputValue": "sg-1234EXAMPLE4567",
        "Description": "Security Group ID"
    }
]

That’s it! We used macros to retrieve the missing EC2 security group name! We hope this example demonstrates the benefits of using macros. They are reusable, easy to invoke and reference. The built-in AWS SAM transform makes authoring macros even easier. Although you can implement equivalent functionality with crhelper (as we did in part 1), using macros does not require you to account for properly responding to all operation events in your Python code. Moreover, crhelper does not allow you to modify the template before you submit it for CREATE, UPDATE, and DELETE stack operations.

Note: If you use the console or the create-stack and update-stack AWS CLI commands, your template cannot be larger than 51,200 bytes. You can avoid this restriction by using AWS SAM, as we’ve done here.

Cleanup

Use the following commands to delete the resources we created in this option.

aws cloudformation delete-stack --stack-name securitygroup-retriever-example
aws cloudformation delete-stack --stack-name simple-sg-macro
aws s3 rm s3://unique-s3-bucket

Conclusion

In this post, we shared an example of how to use AWS CloudFormation macros to customize AWS CloudFormation. Macros allow you to add utilities and transformations before templates are processed, and those utilities can go beyond the scenarios used in the examples in this series. For more information, see Using AWS CloudFormation macros to perform custom processing on templates in the AWS CloudFormation User Guide and the examples on GitHub.

In part 3 of this series, we’ll conclude with a deep dive into AWS CloudFormation resource types and how you can use them to retrieve any AWS service property using AWS CloudFormation. Check out the other posts in this three-part series:

We’ve included our social media contact info below, so please reach out with any comments or questions. We look forward to hearing from you!

About the Author

Gokul Sarangaraju profile image Gokul Sarangaraju is a Senior Technical Account Manager at AWS. He helps customers adopt AWS services and provides guidance in AWS cost and usage optimization. His areas of expertise include delivering solutions using AWS CloudFormation, various other automation techniques. Outside of work, he enjoys playing volleyball and poker – Set, Spike, All-In! You can find him on twitter at @saranggx.
Luis Colon is a Senior Developer Advocate at AWS specializing in CloudFormation. Over the years he’s been a conference speaker, an agile methodology practitioner, open source advocate, and engineering manager. When he’s not chatting about all things related to infrastructure as code, DevOps, Scrum, and data analytics, he’s golfing or mixing progressive trance and deep house music. You can find him on twitter at @luiscolon1.