hashicorp / terraform-provider-aws

The AWS Provider enables Terraform to manage AWS resources.
https://registry.terraform.io/providers/hashicorp/aws
Mozilla Public License 2.0
9.61k stars 9k forks source link

aws_acm_certificate_validation - add simple example of an ACM validation with a wildcard alternate name #16913

Open shorn opened 3 years ago

shorn commented 3 years ago

Community Note

Description

TL:DR; please add a simple example of how to create ACM certificate validation with a wildcard alternate name.

Motivation

Cloudfront only supports one ACM certificate per distribution, as per: https://aws.amazon.com/premiumsupport/knowledge-center/associate-ssl-certificates-cloudfront/

That means, in order to use a HTTPS/SSL protected CF distribution with multiple domains, you need a cert that is valid for both the root domain (e.g. example.com) and the wildcard domains (.e.g. *.example.com).

Note that the simple and obvious for_each solution from the current documentation doesn't work out of the box for a certificate with a wildcard SAN. AWS generates two separate validation options for the root and wildcard with exactly the same CNAME. The example from the documentation will then then fail when TF tries to create the aws_route53_record because of the duplicate CNAME values.

Problem

Because of changes and problems with cert validation over Terraform's history, it's difficult to find a simple example of how to use TF for this fairly basic requirement - there's a bunch of old/stale information out there going all the way back to doing stuff like calling flatten() with string interpolation from back when 0.11 was current.

By "changes and problems" - I'm talking about the "instability of domain_validation_options" when AWS messed up and changed the implementation so the options were listed in unstable order for a while (causing TF to want to re-create certificates unnecessarily). And then there was the change-over from list to set in v3.x of the provider. All these issues have left a whole lot of "maybe try this" solutions for old versions of TF littered around the place (Github issue repositories, stack overflow, etc.)

Proposed solution

It would be valuable to the community if there was a simple example of how to use TF to achieve this in the documentation.

If there is no way to do this with Terraform currently - it would be helpful to call that explicitly out for users in the doco. Since this is the only way to use a single Cloudfront distribution with wildcards, it's intuitive to expect it would be simple to do it with TF.

Please note this is not intended to be a criticism of the TF API in this area - the whole API on the AWS-side seems weird and janky to me (returning duplicate CNAMEs, for example). But if TF does have a solution for this, I think it would save both users and TF developers a bunch of time and frustration if the solution were documented as an example.

Sorry about the length of this; pretty sure the actual example, if it exists, would be shorter than this description. Think of the effort I took to write all this as evidence of how frustrating this is.

New or Affected Resource(s)

Potential Terraform Configuration

Sorry, I have no idea. I can't figure out what it should look like or find a simple example - that's why I think one should be added.

My current approach is trying to learn the new *()** for_each syntax to see if I can somehow filter out one of the duplicate domain_validation_options.

*()** new to me anyway - I've hit this whole issue while trying to upgrade from TF v0.12 with AWS provider 2.x - incredibly frustrating and kind of worrying since I mangled the route53 records for my previous certificate.

Edit: I figured it out for my use-case. Here's an article I wrote about managing multiple wild-carded domains, the example could easily be further reduced and added to the doco: https://kopi.cloud/blog/2021/terraform-aws_acm_certificate-wildcards/

References

Here's a random example I found that seems relevant, though I don't know if it works or not. https://github.com/azavea/terraform-aws-acm-certificate/issues/3

renehernandez commented 3 years ago

I went through this today and I ended up with a solution that doesn't require declaring a local variable in advance.

Instead, it leverages the fact that AWS ACM will generate the same validation data for both *.example.com and example.com by filtering the map of values to exclude any non-wildcard SAN.

resource "cloudflare_record" "certificate_validation" {
  for_each = {
    for dvo in aws_acm_certificate.example.domain_validation_options : dvo.domain_name => {
      name    = dvo.resource_record_name
      record  = dvo.resource_record_value
      type    = dvo.resource_record_type
    }
   # Skips the domain if it doesn't contain a wildcard
    if length(regexall("\\*\\..+", dvo.domain_name)) > 0
  }

  zone_id = cloudflare_zone.example.id
  name    = each.value.name
  value = each.value.record
  type    = each.value.type
  ttl     = 1
  proxied = false
}

Notes

Symbianx commented 2 years ago

@renehernandez's was a nice solution, I was able to overcome the first limitation by checking if the wildcard domain exists in the certificate instead of checking if *. is part of the string:

resource "aws_route53_record" "default" {
  for_each = {
  for dvo in aws_acm_certificate.default.domain_validation_options : dvo.domain_name => {
    name   = dvo.resource_record_name
    record = dvo.resource_record_value
    type   = dvo.resource_record_type
  }
  // Skips the validation record if the certificate contains a wildcard for the same domain. Needed because AWS returns the same validation records for the wildcard domain.
  if contains(concat([aws_acm_certificate.default.domain_name], tolist(aws_acm_certificate.default.subject_alternative_names)), "*.${dvo.domain_name}") == false
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.acm[each.key].zone_id
}

Arguably, the whole contains and concat part is not the most readable.

FlorinOprina commented 2 years ago

My solution, using locals:


locals {
  zone_map = [
    { name : "example.com",
    zone_id : "Z1230000" },
    { name : "example.org",
    zone_id : "Z4560000" },
    { name : "example.co.uk",
    zone_id : "Z7890000" }
  ]
}

resource "aws_acm_certificate" "certificate" {
  domain_name       = "example.com"
  validation_method = "DNS"
  subject_alternative_names = [
    "example.org",
    "example.co.uk"
  ]
}

resource "aws_route53_record" "certificate_records" {
  for_each = {
    for dvo in aws_acm_certificate.certificate.domain_validation_options : dvo.domain_name => {
      name    = dvo.resource_record_name
      record  = dvo.resource_record_value
      type    = dvo.resource_record_type
      zone_id = [for item in local.zone_map : item.zone_id if item.name == dvo.domain_name][0]
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = each.value.zone_id
}

resource "aws_acm_certificate_validation" "certificate_validation" {
  certificate_arn         = aws_acm_certificate.certificate.arn
  validation_record_fqdns = [for record in aws_route53_record.certificate_records : record.fqdn]
}
psypuff commented 5 months ago

Came up with a simpler workaround, using dvo.resource_record_name as the for_each key instead of dvo.domain_name:

resource "aws_route53_record" "example" {
  for_each = {
    for dvo in aws_acm_certificate.example.domain_validation_options : dvo.resource_record_name => {
      record  = dvo.resource_record_value
      type    = dvo.resource_record_type
    }...
  }

  allow_overwrite = true
  name            = each.key
  records         = [each.value[0].record]
  ttl             = 60
  type            = each.value[0].type
  zone_id         = aws_route53_zone.example.zone_id
}

Since the ellipsis (...) groups by the key it requires that extra index when referencing the map values (each.value[0]). I'm sure there's a way to overcome it but didn't want to over-complicate the solution.

dcousens commented 18 hours ago

Alternatively, you can put

if !startswith(dvo.domain_name, "*")

In the for_each block, like the following

resource "aws_route53_record" "example" {
  for_each = {
    for dvo in aws_acm_certificate.example.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
    if !startswith(dvo.domain_name, "*")
  }

  name    = each.value.name
  records = [each.value.record]
  ttl     = 60
  type    = each.value.type
  zone_id = aws_route53_zone.example.zone_id
}