λ
Creating ACM certificates via CloudFormation is easy, but validation is tedious.
This template automates ACM certificate validation using a Lambda custom resource. The Lambda polls CloudFormation stack events for DNS validation details, creates the required records, and completes when ACM validates the certificate.
Description: Deploys an ACM certificate and DNS validation records
Parameters:
zoneName:
Type: String
domainName:
Type: String
Resources:
certificate:
Type: AWS::CertificateManager::Certificate
Properties:
DomainName: !Ref domainName
SubjectAlternativeNames:
- !Sub "*.${domainName}"
ValidationMethod: DNS
certValidationResource:
Type: Custom::CertValidation
Properties:
ServiceToken: !GetAtt lambdaFunction.Arn
HostedZoneName: !Ref zoneName
StackName: !Ref 'AWS::StackName'
lambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
Path: "/"
Policies:
- PolicyName: root
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: '*'
- Effect: Allow
Action:
- route53:ChangeResourceRecordSets
- route53:ListHostedZonesByName
- cloudformation:DescribeStackEvents
Resource: '*'
lambdaFunction:
Type: AWS::Lambda::Function
Properties:
Runtime: python3.6
Timeout: '300'
Handler: index.handler
Role: !GetAtt lambdaRole.Arn
Code:
ZipFile: |
#!/usr/bin/env python3
import cfnresponse
import boto3
import re
import time
def get_zone_id_from_name(zone_name):
r53 = boto3.client('route53')
r = r53.list_hosted_zones_by_name(DNSName=zone_name)
for hz in r['HostedZones']:
return hz['Id']
def get_stack_cert_event(stack_name):
cfn = boto3.client('cloudformation')
params = {'StackName': stack_name}
event = None
while True:
r = cfn.describe_stack_events(**params)
for e in r['StackEvents']:
if (
e['ResourceType'] == 'AWS::CertificateManager::Certificate' and
e['ResourceStatus'] == 'CREATE_IN_PROGRESS' and
'ResourceStatusReason' in e and
'Content of DNS Record' in e['ResourceStatusReason']
):
event = e
if 'NextToken' in r:
params['NextToken'] = r['NextToken']
else:
break
return event
def parse_event(event):
reason = event['ResourceStatusReason']
m = re.search(r'\{Name\: (.+),\s?Type\: (.+),\s?Value\: (.+)\}', reason)
if m:
dns = {
'Name': m.group(1),
'Type': m.group(2),
'Value': m.group(3)
}
return dns
return None
def upsert(hosted_zone_id, dns_record):
r53 = boto3.client('route53')
r53.change_resource_record_sets(
HostedZoneId=hosted_zone_id,
ChangeBatch={
'Changes': [{
'Action': 'UPSERT',
'ResourceRecordSet': {
'Name': dns_record['Name'],
'Type': dns_record['Type'],
'TTL': 60,
'ResourceRecords': [{
'Value': dns_record['Value']
}]
}
}]
}
)
def handler(event, context):
try:
if event['RequestType'] == 'Create':
cert_event = None
while cert_event is None:
cert_event = get_stack_cert_event(
event['ResourceProperties']['StackName'])
time.sleep(5)
dns_record = parse_event(cert_event)
if dns_record:
hosted_zone_id = get_zone_id_from_name(
event['ResourceProperties']['HostedZoneName'])
upsert(hosted_zone_id, dns_record)
except Exception as e:
print(e)
finally:
if 'Offline' not in event['ResourceProperties']:
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
if __name__ == '__main__':
event = {
'ResourceProperties': {
'HostedZoneName': 'x.x.x',
'Offline': True
}
}
handler(event, None)
Outputs:
certificateArn:
Value: !Ref certificate
Comments