Building a Best Practice CloudFormation Custom Resource Pattern

Search for a command to run...

No comments yet. Be the first to comment.
For many years it was my main work to reverse engineer software installation and configuration for hyper-scaled deployment automation and for OS provisioning for Windows. Early on it was evident that for OS and software provisioning it was extremely ...
Why Least Privilege Security Engineering Is Frequently Skipped or Done Loosely In a previous life, I was on a team that reviewed the IAM policies specified by developers when they created new Cloud applications or required additional permissions for ...

Obscuring sensitive information like AWS account IDs in screenshots and videos is tedious and error-prone. Even with video editing tools that simplify the process, I still have to repeatedly add and remove blurring boxes as the view changes. You know...

AWS CloudShell joins the ranks of hostless shells for operating in your cloud environment. Cloud shells are a huge help to training and enablement scenarios because they remove the pain of fussy configuration of a user-owned endpoint - which can have...

This article is the third and final of a series. Part 1 justified that human-performed DevOps checklists are essentially source code, and according to GitOps principles, belong in Git just like any other code required for successfully managing a soft...

There are always those who feel checklists are an unnecessary waste of time because they think they can always remember the basics of the steps involved to complete a task. Many are also not aware of the huge, cross-discipline benefits that can come ...

I was looking to add the ability for users of a CloudFormation template to be able to specify networking, but without overcomplicating the existing parameter set or the required information gathering. Meeting the requirement ended up being the gateway to learning how to create CloudFormation Custom Resources backed by Lambda. While I was at it, I made sure the code could be reused for future Custom Resource needs. This article shares the simplest possible way I could devise for automatically gathering the right information from the fewest parameters. It also presents a pattern for a well written CloudFormation Custom Function with enhanced exception logging and compact code.
"The Whole Berg - Above and Below the Waterline" Posts in the “Mission Impossible Code” Series contain toolsmithing information that is not necessary to reuse the solution - use the iceberg glyphs to know when the content is diving below the water line into “How I Made This”. The content is also designed to be skim-read.
"Tip of the Iceberg - Concise Summary Discussion" The “Tip of the Iceberg” icon indicates as simple as possible info on why and what in order to assess and implement.
"Deep Dive - Below The Water Line Discussion" The “Below The Water Line” icon indicates a deep dive into nitty gritty details of how to take a similar approach to building solutions.
During this effort I mused how we sometimes do the equivalent of Pearl Diving when taking on new skills. Its the idea of deep learning a stack of one or more new things while under the pressure of needing the end state code to reflect a maturity level significantly higher than your beginner expertise in that stack. I have captured the details about Perl Diving - and when you should use it (because you usually should not) - in a companion post titled Pearl Diving - Just In Time Learning of Mature Coding Habits For a New Stack
"Tip of the Iceberg - Concise Summary Discussion" Mission Objectives and Parameters articulate the final objectives that emerged from both the preplanning and build process. Code Summary gives an out line of the code fragments. Code Call Outs highlights significant constraints, innovations and possible alternatives in the code.
This code was created for the solution GitLab HA Scaling Runner Vending Machine for AWS
#Arguments: Vpc-id or "DefaultVPC"
#Returns: vpc-id, number of subnets and ordered list of subnetids and az ids.
# The index of these two return lists are correlated if it is desirable to choose less than the whole list using the CloudFormation function "Select" against both lists.
VPCInfoLambda:
Type: 'AWS::Lambda::Function'
Properties:
Description: Returns the lowercase version of a string
MemorySize: 256
Runtime: python3.8
Handler: index.handler
Role: !GetAtt CFCustomResourceLambdaRole.Arn
Timeout: 240
Code:
ZipFile: |
import logging
import traceback
import signal
import cfnresponse
import boto3
LOGGER = logging.getLogger()
LOGGER.setLevel(logging.INFO)
def handler(event, context):
# Setup alarm for remaining runtime minus a second
signal.alarm((int(context.get_remaining_time_in_millis() / 1000)) - 1)
try:
LOGGER.info('REQUEST RECEIVED:\n %s', event)
LOGGER.info('REQUEST RECEIVED:\n %s', context)
if event['RequestType'] == 'Delete':
LOGGER.info('DELETE!')
cfnresponse.send(event, context, "SUCCESS", {
"Message": "Resource deletion successful!"})
return
elif event['RequestType'] == 'Update':
LOGGER.info('UPDATE!')
cfnresponse.send(event, context, "SUCCESS",{
"Message": "Resource update successful!"})
elif event['RequestType'] == 'Create':
LOGGER.info('CREATE!')
request_properties = event.get('ResourceProperties', None)
VpcToGet = event['ResourceProperties'].get('VpcToGet', '')
ec2 = boto3.resource('ec2')
VpcCheckedList = []
TargetVPC = None
vpclist = ec2.vpcs.all()
for vpc in vpclist:
VpcCheckedList.append(vpc.id)
if VpcToGet == "DefaultVPC" and vpc.is_default == True:
TargetVPC=vpc
elif vpc.vpc_id == VpcToGet:
TargetVPC=vpc
if TargetVPC == None:
raise Exception(f'VPC {VpcToGet} was not found among the ones in this account and region, VPC which are: {", ".join(VpcCheckedList)}')
else:
VPCOutput = TargetVPC.id
subidlist = []
zoneidlist = []
subnets = list(TargetVPC.subnets.all())
for subnet in subnets:
subidlist.append(subnet.id)
zoneidlist.append(subnet.availability_zone)
subidOutput = ",".join(subidlist)
zoneidOutput = ",".join(zoneidlist)
if not subnets:
raise Exception(f'There are no subnets in VPC: {VpcToGet}')
LOGGER.info('subnet ids are: %s', subidOutput)
LOGGER.info('zone ids are: %s', zoneidOutput)
responseData = {}
responseData['VPC_id'] = VPCOutput
responseData['OrderedSubnetIdList'] = subidOutput
responseData['OrderedZoneIdList'] = zoneidOutput
responseData['SubnetCount'] = len(subidlist)
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData)
except Exception as err:
AccountRegionInfo=f'Occured in Account {context.invoked_function_arn.split(":")[4]} in region {context.invoked_function_arn.split(":")[3]}'
FinalMsg=str(err) + ' ' + AccountRegionInfo
LOGGER.info('ERROR: %s', FinalMsg)
LOGGER.info('TRACEBACK %s', traceback.print_tb(err.__traceback__))
cfnresponse.send(event, context, "FAILED", {
"Message": "{FinalMsg}"})
def timeout_handler(_signal, _frame):
'''Handle SIGALRM'''
raise Exception('Time exceeded')
signal.signal(signal.SIGALRM, timeout_handler)
#Custom Function IAM Role Declaration
CFCustomResourceLambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
Service:
- "lambda.amazonaws.com"
Action:
- "sts:AssumeRole"
Policies:
- PolicyName: "lambda-write-logs"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action:
- "logs:CreateLogGroup"
- "logs:CreateLogStream"
- "logs:PutLogEvents"
Resource: "arn:aws:logs:*:*"
- PolicyName: "describe-vpcs-and-subnets"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action:
- "ec2:DescribeVpcs"
- "ec2:DescribeSubnets"
Resource: "*"
#Calling Function to Retrieve Data
LookupVPCInfo:
Type: Custom::VPCInfo
Properties:
ServiceToken: !GetAtt VPCInfoLambda.Arn
VpcToGet: !Ref SpecifyVPCToUse
Fragment That Demonstrates Parameter Collection
#Parameter declaration with important default
Parameters:
SpecifyVPCToUse:
Description: >
DefaultVPC - finds the VPC and configures all of its subnets for you. Otherwise type
in the VPC id of a VPC in the same region where you run the template.
All subnets and azs of the chosen vpc will be used.
The VPC and chosen subnets must be setup in a way that allows the runner instances
to resolve the DNS name and connect to port 443 on the GitLab instance URL you provide.
Default: DefaultVPC
Type: String
# While it is tempting to make the above parameter of type "AWS::EC2::VPC::Id"
# this prevents automatic discovery and usage of the DefaultVPC.
# However, if your organization NEVER uses default VPCs or disables them, changing
# the type to AWS::EC2::VPC::Id actually improves the user experience because users do not have to
# lookup VPC ids in the console.
#Fragment showing using the resultant data from the custom function
InstanceASG:
Type: AWS::AutoScaling::AutoScalingGroup
Properties:
AvailabilityZones: !Split [",",!GetAtt LookupVPCInfo.OrderedZoneIdList]
VPCZoneIdentifier: !Split [",",!GetAtt LookupVPCInfo.OrderedSubnetIdList]
"Deep Dive - Below The Water Line Discussion" The following content is a deep dive below the waterline into the nitty gritty details of how to take a similar approach to building solutions.
NOTE: You do not need this information to successfully leverage this solution.
The following list demonstrates the Architectural thrust of the solution. This approach is intended to be pure to simplicity of operation and maintenance, rather than purity of a language or framework or development methodology. It is also intended to have the least possible dependencies. The below is a mix of a) previously committed dispositions for the Overall Solution, b) predetermined design points and c) things discovered and adopted during the development process (emergent or organic solution architecture component).
The notation “<==>”, which may contain logic like “<= AND =>” is my attempt to visually reflect the dynamic tension or trade-offs inherent in using heuristics to commit to fixing positions on a spectrum of possibilities. During the solution formulation these positions fluctuate as you try to simultaneously tune multiple, interacting vectors through trial and error. Even when I do it on purpose, I still can’t completely understand how I am tuning multiple vectors at once and why the results of the process repetitively turn out to effectively solve for multiple vectors. However the internals work, once you’ve produced a sufficiently satisfactory solution, their final positions reflect a complete tuning. They are sort of like custom presets on a sound equalizer. By documenting them as I have done here - I reveal my impression of the final tuning. I feel this does at least three things for the consumer of this information:
The overall solution is solving for “Allow Network Configuration Selection, Using the Least New Parameters and Without Complicating the Existing Easiest User Experience Case”
Overall Solution Requirement: (Satisfied) Ensure that VPC / networking can be specified.
Overall Solution Requirement: (Satisfied) Add the minimum number of new parameters to give the ability to select a network location for the scaling group.
Overall Solution Requirement: (Satisfied) Retaining simplicity of previous version to automatically use the default VPC by default.
Overall Solution Limitation: Cannot specify subnets / availability zones
Overall Solution Limitation: Cannot use AWS::EC2::VPC::Id to make VPCs list a drop down in UI based template execution.
CF Custom Resource Requirement: (Satisfied) Be compact.
CF Custom Resource Requirement: (Satisfied) Implement proper exception handling <==> despite size concerns <==> be compact.
CF Custom Resource Requirement: (Satisfied) Have exceptions report maximum context and, where possible, troubleshooting hints.
CF Custom Resource Requirement: (Satisfied) Always build using least privileged security.
CF Custom Resource Desirement: (Satisfied) Leverage common and available Python modules to simplify the code.
CF Custom Resource Desirement: (Satisfied) Have this function be the basis of a template for future reuse.
CF Custom Resource Serendipity: (Satisfied) Support timeout functionality for Lambda serverless.