CloudFormation Loop

At a project I’ve been working on CloudFormation was used. It’s not my favorite method for Infrastructure as Code (IaC) and I wouldn’t choose it, however it’s sometimes not a decision you can make, but its definitely better than clickops. One drawback is that it is a bit limited in how to set up a dynamic set of resources when the number of resources are not known in advance.

There are times when it would be nice to be able perform a for loop in CloudFormation. At time of writing, this is not possible, however it does support the Transform parameter, where you can utilize lambda to perform changes.

Larger enterprises sometimes offer a set of pre-made infrastructure templates using the AWS Service Catalog, where this can come in handy.

Let’s say you for some reason want to set up a set of lambdas which is served through an API gateway that prints “Hello World!”, which takes api-gateway and a path as a simple example. And now you want to offer a way for whoever using this template to enter a list of strings where they can either set up arbitrary number of deployments, e.g. path1, path2 and path3. Normally this would mean three stacks and a lot of clicks.

A single template can be set up using:

AWSTemplateFormatVersion: "2010-09-09"

Description: AWS API Gateway with a Lambda Integration

Parameters:
  PathName:
    Type: String
  ApiGatewayRestApi:
    Type: String

Resources:

  ApiGatewayMethod:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref ApiGatewayRestApi
      Description: Test Integration
      ConnectionType: INTERNET
      CredentialsArn: !GetAtt ApiGatewayIamRole.Arn
      PassthroughBehavior: WHEN_NO_MATCH
      TimeoutInMillis: 29000
      IntegrationMethod: POST
      IntegrationType: AWS_PROXY
      PayloadFormatVersion: "2.0"
      IntegrationUri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaFunction.Arn}/invocations"

  ApiGatewayIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: ""
            Effect: "Allow"
            Principal:
              Service:
                - "apigateway.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/"
      Policies:
        - PolicyName: LambdaAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action: "lambda:*"
                Resource: !GetAtt LambdaFunction.Arn

  ApiGatewayStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      DeploymentId: !Ref ApiGatewayDeployment
      Description: Lambda API Stage v0
      ApiId: !Ref ApiGatewayRestApi
      StageName: !Ref PathName

  ApiGatewayDeployment:
    Type: AWS::ApiGatewayV2::Deployment
    DependsOn: ApiGatewayMethod
    Properties:
      Description: Lambda API Deployment
      ApiId: !Ref ApiGatewayRestApi

  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          def handler(event, context):
            response = {
              'isBase64Encoded': False,
              'statusCode': 200,
              'headers': {},
              'multiValueHeaders': {},
              'body': 'Hello, World!'
            }
            return response
      Description: AWS Lambda function
      FunctionName: !Sub "lambda-function-${PathName}"
      Handler: index.handler
      MemorySize: 256
      Role: !GetAtt LambdaIamRole.Arn
      Runtime: python3.7
      Timeout: 60

  LambdaIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "lambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/"

  ApiGatewayResource:
    Type: AWS::ApiGatewayV2::Route
    DependsOn:
      - LambdaFunction
      - ApiGatewayMethod
    Properties:
      ApiId: !Ref ApiGatewayRestApi
      RouteKey: !Sub "POST /lambda-function-${PathName}"
      Target: !Join
        - /
        - - integrations
          - !Ref ApiGatewayMethod

Outputs:
  LambdaArn:
    Value: !Ref LambdaFunction

Think of this as a module in Terraform. In order to deploy this multiple times in the same go we need to implement the Transform lambda function. Here is a python function that takes a commaseparated list and duplicates the template (with support for placeholders).

import copy
import json
import re

MACRO_NAME = 'ListForEach'

def process_template(template: dict, templateParameterValues: dict) -> tuple:
    new_template = copy.deepcopy(template)
    
    if not transform_template_section(template, new_template, templateParameterValues, 'Resources'):
        return 'failed', template
    if not transform_template_section(template, new_template, templateParameterValues, 'Outputs'):
        return 'failed', template
    return 'success', new_template

def transform_template_section(template: dict, new_template: dict, templateParameterValues: dict, section: str) -> bool:
    for name, resource in template[section].items():
        if MACRO_NAME in resource:
            # Get the number of times to multiply the resource
            commaSeparatedList = new_template[section][name].pop(MACRO_NAME)
            # Remove the original resource from the template but take a local copy of it
            resourceToMultiply = new_template[section].pop(name)
            # Create a new block of the resource multiplied with names ending in the iterator and the placeholders substituted
            resourcesAfterMultiplication = multiply(name, resourceToMultiply, commaSeparatedList, templateParameterValues)
            if not set(resourcesAfterMultiplication.keys()) & set(new_template[section].keys()):
                new_template[section].update(resourcesAfterMultiplication)
            else:
                return False
    return True

def update_placeholder(resource_structure, iteration, listValues):
    resourceString = json.dumps(resource_structure)
    indexPlaceHolderCount = resourceString.count('%d') + resourceString.count('%s')

    if indexPlaceHolderCount == 0:
        print("No occurences of placeholder found in JSON, therefore nothing will be replaced")
        return resource_structure
    
    # Placeholders exists
    print("Found {} occurrences of placeholder in JSON, replacing with iterator value {}".format(indexPlaceHolderCount, iteration))

    regex = r"(\%d|\%s)"
    matches = re.findall(regex, resourceString, re.MULTILINE)
    placeHolderReplacementValues = [iteration if m == '%d' else listValues[iteration] for m in matches]
    #Replace the decimal placeholders using the list - the syntax below expands the list
    resourceString = resourceString % (*placeHolderReplacementValues,)
    return json.loads(resourceString)


def multiply(resource_name, resource_structure, listValues, templateParameterValues):
    resources = {}
    #Loop according to the number of times we want to multiply, creating a new resource each time
    if isinstance(listValues, dict) and 'Ref' in listValues:
        listValues = templateParameterValues[listValues['Ref']]
        count = len(listValues)
    else:
        count = listValues
    for iteration in range(count):
        print("Multiplying '{}', iteration count {}".format(resource_name,iteration))        
        multipliedResourceStructure = update_placeholder(resource_structure, iteration, listValues)
        resources[resource_name+str(iteration)] = multipliedResourceStructure
    return resources


def handler(event, context):
    result = process_template(event['fragment'], event['templateParameterValues'])
    return {
        'requestId': event['requestId'],
        'status': result[0],
        'fragment': result[1],
    }

Simply deploy it with:

---
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  ListForEach macro
  To create resources from list of strings

Resources:
  Macro:
    Type: AWS::CloudFormation::Macro
    Properties:
      Name: ListForEach
      FunctionName: !GetAtt ListForEachMacroFunction.Arn
  ListForEachMacroFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src
      Handler: index.handler
      Runtime: python3.9
      Timeout: 5

Now everthing is set up, and you can start using it 🎉

Simply invoke it with the Transform parameter:

AWSTemplateFormatVersion: "2010-09-09"
Transform: ListForEach
Parameters:
  PrivateSubdomains:
    Type: CommaDelimitedList
    Description: Comma separated list of private subdomains
    Default: "priv1,priv2"
Resources:
  PathToPublicBucket:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://some-bucket/cloudformation-manifest.yaml
      Parameters:
        PathName: "public-path-%d-%s"
        ApiGatewayRestApi: !Ref ApiGatewayRestApi
    ListForEach: !Ref PrivateSubdomains
Outputs:
  PathToPrivateBucketArn:
    Value: !Ref PathToPrivateBucket%d
    ListForEach: !Ref PrivateSubdomains

Which will expand it to the following before running it

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  PrivateSubdomains:
    Type: CommaDelimitedList
    Description: Comma separated list of private subdomains
    Default: "priv1,priv2"
Resources:
  PathToPublicBucket0:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://some-bucket/cloudformation-manifest.yaml
      Parameters:
        PathName: "public-path-0-priv1"
        ApiGatewayRestApi: !Ref ApiGatewayRestApi
  PathToPublicBucket1:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://some-bucket/cloudformation-manifest.yaml
      Parameters:
        PathName: "public-path-1-priv2"
        ApiGatewayRestApi: !Ref ApiGatewayRestApi
Outputs:
  PathToPrivateBucketArn0:
    Value: !Ref PathToPrivateBucket0
  PathToPrivateBucketArn1:
    Value: !Ref PathToPrivateBucket1

You can see the full example in this repository

Marius.bio

© 2025 Marius G. All rights reserved.

GitHub