Creating ACM certificates via CloudFormation is cool, but validation isn’t.

The below template will create the ACM certificate and a Lambda custom resource.

The custom resource will poll the CloudFormation stack waiting for the ACM certificate resource to output an event with the DNS validation record details.

Once the event has been emitted, the custom resource will go on to create the required DNS records for validation. Once ACM has performed it’s validation, the stack will finish creating successfully.

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