AWS CloudFormation - Custom variables in templates

Is there any way to define shortcuts for often-used values derived from CloudFormation template parameters?

For example - I've got a script that creates a Multi-AZ Project stack with ELB name project and two instances behind the ELB called project-1 and project-2. I only pass ELBHostName parameter to the template and later on use it to construct :

"Fn::Join": [
    ".", [
        { "Fn::Join": [ "", [ { "Ref": "ELBHostName" }, "-1" ] ] },
        { "Ref": "EnvironmentVersioned" },
        { "Ref": "HostedZone" }
    ]
]

This construction or very similar is repeated many times throughout the template - to create the EC2 host name, Route53 records, etc.

Instead of repeating that over and over again I would like to assign the output of that Fn::Join to a variable of some sort and only refer to that, just like I can with "Ref": statement.

Ideally something like:

Var::HostNameFull = "Fn::Join": [ ... ]
...
{ "Name": { "Ref": "Var::HostNameFull" } }

or something similarly simple.

Is that possible with Amazon CloudFormation?


Solution 1:

I was looking of the same functionality. Using a nested stack as SpoonMeiser suggested came to mind, but then I realised that what I actually needed was custom functions. Luckily CloudFormation allows the use of AWS::CloudFormation::CustomResource that, with a bit of work, allows one to do just that. This feels like overkill for just variables (something I would argue that should have been in CloudFormation in the first place), but it gets the job done, and, in addition, allows for all the flexibility of (take your pick of python/node/java). It should be noted that lambda functions cost money, but we're talking pennies here unless you create/delete your stacks multiple times per hour.

First step is to make a lambda function on this page that does nothing but take the input value and copy it to the output. We could have the lambda function do all sorts of crazy stuff, but once we have the identity function, anything else is easy. Alternatively we could have the lambda function being created in the stack itself. Since I use many stacks in 1 account, I would have a whole bunch of leftover lambda functions and roles (and all stacks need to be created with --capabilities=CAPABILITY_IAM, since it also needs a role.

Create lambda function

  • Go to lambda home page, and select your favourite region
  • Select "Blank Function" as template
  • Click "Next" (don't configure any triggers)
  • Fill in:
    • Name: CloudFormationIdentity
    • Description: Returns what it gets, variable support in Cloud Formation
    • Runtime: python2.7
    • Code Entry Type: Edit Code Inline
    • Code: see below
    • Handler: index.handler
    • Role: Create a Custom Role. At this point a popup opens that allows you to create a new role. Accept everything on this page and click "Allow". It will create a role with permissions to post to cloudwatch logs.
    • Memory: 128 (this is the minimum)
    • Timeout: 3 seconds (should be plenty)
    • VPC: No VPC

Then copy-paste the code below in the code field. The top of the function is the code from the cfn-response python module, that does only get auto-installed if the lambda-function is created through CloudFormation, for some strange reason. The handler function is pretty self-explanatory.

from __future__ import print_function
import json

try:
    from urllib2 import HTTPError, build_opener, HTTPHandler, Request
except ImportError:
    from urllib.error import HTTPError
    from urllib.request import build_opener, HTTPHandler, Request


SUCCESS = "SUCCESS"
FAILED = "FAILED"


def send(event, context, response_status, reason=None, response_data=None, physical_resource_id=None):
    response_data = response_data or {}
    response_body = json.dumps(
        {
            'Status': response_status,
            'Reason': reason or "See the details in CloudWatch Log Stream: " + context.log_stream_name,
            'PhysicalResourceId': physical_resource_id or context.log_stream_name,
            'StackId': event['StackId'],
            'RequestId': event['RequestId'],
            'LogicalResourceId': event['LogicalResourceId'],
            'Data': response_data
        }
    )
    if event["ResponseURL"] == "http://pre-signed-S3-url-for-response":
        print("Would send back the following values to Cloud Formation:")
        print(response_data)
        return

    opener = build_opener(HTTPHandler)
    request = Request(event['ResponseURL'], data=response_body)
    request.add_header('Content-Type', '')
    request.add_header('Content-Length', len(response_body))
    request.get_method = lambda: 'PUT'
    try:
        response = opener.open(request)
        print("Status code: {}".format(response.getcode()))
        print("Status message: {}".format(response.msg))
        return True
    except HTTPError as exc:
        print("Failed executing HTTP request: {}".format(exc.code))
        return False

def handler(event, context):
    responseData = event['ResourceProperties']
    send(event, context, SUCCESS, None, responseData, "CustomResourcePhysicalID")
  • Click "Next"
  • Click "Create Function"

You can now test the lambda function by selecting the "Test" button, and select "CloudFormation Create Request" as sample template. You should see in your log that the variables fed to it, are returned.

Use variable in your CloudFormation template

Now that we have this lambda function, we can use it in CloudFormation templates. First make note of the lambda function Arn (go to the lambda home page, click the just created function, the Arn should be in the top right, something like arn:aws:lambda:region:12345:function:CloudFormationIdentity).

Now in your template, in the resource section, specify your variables like:

Identity:
  Type: "Custom::Variable"
  Properties:
    ServiceToken: "arn:aws:lambda:region:12345:function:CloudFormationIdentity"
    Arn: "arn:aws:lambda:region:12345:function:CloudFormationIdentity"

ClientBucketVar:
  Type: "Custom::Variable"
  Properties:
    ServiceToken: !GetAtt [Identity, Arn]
    Name: !Join ["-", [my-client-bucket, !Ref ClientName]]
    Arn: !Join [":", [arn, aws, s3, "", "", !Join ["-", [my-client-bucket, !Ref ClientName]]]]

ClientBackupBucketVar:
  Type: "Custom::Variable"
  Properties:
    ServiceToken: !GetAtt [Identity, Arn]
    Name: !Join ["-", [my-client-bucket, !Ref ClientName, backup]]
    Arn: !Join [":", [arn, aws, s3, "", "", !Join ["-", [my-client-bucket, !Ref ClientName, backup]]]]

First I specify an Identity variable that contains the Arn for the lambda function. Putting this in a variable here, means I only have to specify it once. I make all my variables of type Custom::Variable. CloudFormation allows you to use any type-name starting with Custom:: for custom resources.

Note that the Identity variable contains the Arn for the lambda function twice. Once to specify the lambda function to use. The second time as the value of the variable.

Now that I have the Identity variable, I can define new variables using ServiceToken: !GetAtt [Identity, Arn] (I think JSON code should be something like "ServiceToken": {"Fn::GetAtt": ["Identity", "Arn"]}). I create 2 new variables, each with 2 fields: Name and Arn. In the rest of my template I can use !GetAtt [ClientBucketVar, Name] or !GetAtt [ClientBucketVar, Arn] whenever I need it.

Word of caution

When working with custom resources, if the lambda function crashes, you're stuck for between 1 and 2 hours, because CloudFormation waits for a reply from the (crashed) function for an hour before giving up. Therefore it might be good to specify a short timeout for the stack while developing your lambda function.

Solution 2:

I don't have an answer, but did want to point out that you can save yourself a lot of pain by using Fn::Sub in place of Fn::Join

{ "Fn::Sub": "${ELBHostName"}-1.${EnvironmentVersioned}.${HostedZone}"}

Replaces

"Fn::Join": [
    ".", [
        { "Fn::Join": [ "", [ { "Ref": "ELBHostName" }, "-1" ] ] },
        { "Ref": "EnvironmentVersioned" },
        { "Ref": "HostedZone" }
    ]
]

Solution 3:

No. I tried it, but came up empty. The way that made sense to me was to create a Mappings entry called "CustomVariables" and to have that house all my variables. It works for simple Strings, but you can't use Intrinsics (Refs, Fn::Joins, etc.) inside Mappings.

Works:

"Mappings" : {
  "CustomVariables" : {
    "Variable1" : { "Value" : "foo" },
    "Variable2" : { "Value" : "bar" }
  }
}

Won't work:

  "Variable3" : { "Value" : { "Ref" : "AWS::Region" } }

That's just an example. You wouldn't put a standalone Ref in a Variable.

Solution 4:

You could use a nested stack which resolves all your variables in it's outputs, and then use Fn::GetAtt to read the outputs from that stack

Use another stack as nested stack & pass in computed Parameter sample:


  YourResourceName:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: ./YourStackYouUseAsNested.yaml
      Parameters:
        ParamOfNestedStack: !Join [ "-", [ "you-compute-value", !Ref AWS::Region ] ]