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.8k stars 9.15k forks source link

Can't iterate over certificate_validation_records attributes of aws_apprunner_custom_domain_association resource #23460

Open vmignot opened 2 years ago

vmignot commented 2 years ago

Community Note

Terraform CLI and Terraform AWS Provider Version

on darwin_amd64
+ provider registry.terraform.io/hashicorp/aws v4.3.0

Affected Resource(s)

Terraform Configuration Files


resource "aws_apprunner_custom_domain_association" "main" {
  domain_name          = "${local.domain_name}.${data.aws_route53_zone.main.name}"
  service_arn          = aws_apprunner_service.main.arn
  enable_www_subdomain = true
}

resource "aws_route53_record" "main-www" {
  name           = local.domain_name
  set_identifier = local.domain_name

  type    = "CNAME"
  zone_id = data.aws_route53_zone.main.zone_id
  ttl     = 300
  records = [aws_apprunner_service.main.service_url]

  weighted_routing_policy {
    weight = 90
  }
}

resource "aws_route53_record" "main-cert" {
  for_each = {
    for entry in aws_apprunner_custom_domain_association.main.certificate_validation_records : entry.name => {
      name   = entry.name
      record = entry.value
      type   = entry.type
    }
  }

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

Expected Behavior

The resource aws_route53_record.main-cert should be created properly. We should be able to iterate through aws_apprunner_custom_domain_association.main.certificate_validation_records dynamically.

Actual Behavior

β”‚ Error: Invalid for_each argument
β”‚ 
β”‚   on modules/app_runner/main.tf line 94, in resource "aws_route53_record" "main-cert":
β”‚   94:   for_each = {
β”‚   95:     for entry in aws_apprunner_custom_domain_association.main.certificate_validation_records : entry.name => {
β”‚   96:       name   = entry.name
β”‚   97:       record = entry.value
β”‚   98:       type   = entry.type
β”‚   99:     }
β”‚  100:   }
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚     β”‚ aws_apprunner_custom_domain_association.main.certificate_validation_records is a set of object, known only after apply
β”‚ 
β”‚ The "for_each" value depends on resource attributes that cannot be determined until apply, so Terraform cannot predict how many instances will be created. To work around this, use the -target argument to first apply only the resources that the
β”‚ for_each depends on.

Steps to Reproduce

  1. terraform apply
justinretzolk commented 2 years ago

Hey @vmignot πŸ‘‹ Thank you for taking the time to raise this. Based on the error, a targeted apply may be needed in order to work around this due to the values not being known at plan time. This is mentioned as a limitation in the for_each documentation as well, with a note about the possible need for a targeted apply.

After a targeted apply, I suspect this would probably work (though I haven't set up a reproduction, and am not entirely familiar with the structure of this resource, so don't hold me to that). In case you need it, the documentation around targeting resources may be found here.

vmignot commented 2 years ago

Hi, thanks for your reply.

To be maybe more precise, I can't figure why this resource can't work the way acm_certificate_validation works, as it is described here: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validation#dns-validation-with-route-53

Regards,

justinretzolk commented 2 years ago

Hey @vmignot πŸ‘‹ Thank you for following up, and for pointing that out. Looking at the schema for these two resources, I'd think that a similar for_each approach could be used. Perhaps this is due to the aws_acm_certificate resource having Set: defined. I'm going to mark this as a bug so that we can look into it and determine whether this should be possible.

jritsema commented 2 years ago

I was able to reproduce this bug. In case it helps, I noticed that if you remove the aws_route53_record.main-cert resource then run apply, then re-add it, it works as expected.

jritsema commented 2 years ago

Perhaps this is due to the aws_acm_certificate resource having Set: defined.

I tried implementing Set locally and it still didn't work.

I also compared aws_acm_certificate.domain_validation_options with aws_apprunner_custom_domain_association.certificate_validation_records and the only other notable difference is that the one that works implements CustomizeDiffFunc, however, this implementation doesn't seem to do anything that would affect this behavior.

jritsema commented 2 years ago

We may have incorrectly assumed that the aws_acm_certificate.domain_validation_options works in a single apply. I believe I have gotten this to work in the past, however, it appears that it no longer works? see https://github.com/hashicorp/terraform-provider-aws/issues/14447

anilmujagic commented 2 years ago

Using the learnings from here I'm using this workaround:

resource "aws_route53_record" "api_certificate_validation" {
    count = length(aws_apprunner_custom_domain_association.api.certificate_validation_records)

    name = aws_apprunner_custom_domain_association.api.certificate_validation_records.*.name[count.index]
    type = aws_apprunner_custom_domain_association.api.certificate_validation_records.*.type[count.index]
    ttl = 300
    zone_id = data.aws_route53_zone.api.id

    records = [aws_apprunner_custom_domain_association.api.certificate_validation_records.*.value[count.index]]
}
mwaaas commented 2 years ago

@anilmujagic

That will still result on the same error

The "count" value depends on resource attributes that cannot be determined β”‚ until apply, so Terraform cannot predict how many instances will be β”‚ created. To work around this, use the -target argument to first apply only β”‚ the resources that the count depends on

hostmit commented 2 years ago

Hey! Is there a solution to it?

mwaaas commented 2 years ago

I ended up running the service with the target module to make sure dependent resources are being created first.

kevincloud commented 1 year ago

@justinretzolk The documentation does address this. However, I think most of us are boggled about why Terraform has trouble with this. Terraform does its own dependency mapping, and therefore should be able to run the aws_route53_record resource after the aws_apprunner_custom_domain_association resource is created. After that resource is created, the certificate_validation_records values are available for the aws_route53_record resource to use.

This is really not expected behavior. Due to dependency mapping, Terraform infers a promise that these values can be used in the same script. Just like how the endpoint for an aws_db_instance is available for use in the same configuration, so should the certificate_validation_records map be available for use within a script.

As for solutions, for larger configurations, the -target workaround is not even an option. For the other workaround:

When working with unknown values in for_each, it's better to define the map keys statically in your configuration and place apply-time results only in the map values.

There are no examples of how to do this. It's a convoluted message and I personally can't make sense of it (especially since the values are available at the time the script runs the resource block...again, due to dependency mapping). It would be great if in the documentation, there would be an example of how to define the map keys statically for use in the same config.

Is that something HashiCorp can provide?

holograph commented 1 year ago

"I think most of us are boggled about why Terraform has trouble with this" pretty much nails it. Unfortunately this is still a problem as of 1.3.4 with provider 4.26, and it took a lot of attempts to find a reasonable workaround (via the tolist(...)[0] hack). I'm obviously missing the deeper technical context, but... this does seem far simpler than everything else Terraform already does, what am I missing?

TechplexEngineer commented 1 year ago

@holograph can you elaborate on the tolist hack? How does one use that to avoid needing to use -target?

holograph commented 1 year ago

I used the following code successfully @TechplexEngineer. As I understand it, the tolist(...) (instead of direct array access) has Terraform create a runtime dependency as opposed to a statically known one - which is the default for array access, and obviously won't work because the DNS values aren't known ahead of time - and allows validation to function correctly. This obviously won't work in case of multiple validation options, but honestly who has those ;-)

resource "aws_acm_certificate" "example" {
  domain_name       = var.domain_name
  validation_method = "DNS"

}

resource "aws_acm_certificate_validation" "example" {
  certificate_arn = aws_acm_certificate.example.arn
}

resource "aws_route53_record" "example" {
  zone_id = var.route53_public_zone_id
  ttl     = 60
  type    = "CNAME"
  name    = tolist(aws_acm_certificate.example.domain_validation_options)[0].resource_record_name
  records = [tolist(aws_acm_certificate.example.domain_validation_options)[0].resource_record_value]
}
fracampit commented 1 year ago

Full working example:

resource "aws_apprunner_service" "this" {
  . . .
}

resource "aws_apprunner_custom_domain_association" "this" {
  domain_name = "custom-sub-domain.my-domain.com"
  service_arn = aws_apprunner_service.this.arn
}

data "aws_route53_zone" "this" {
  name = "my-domain.com"
}

resource "aws_route53_record" "validation_records_linglinger_1" {
  name     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[0].name
  type     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[0].type
  records  = [tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[0].value]
  ttl      = 300
  zone_id  = data.aws_route53_zone.this.id
}

resource "aws_route53_record" "validation_records_linglinger_2" {
  name     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[1].name
  type     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[1].type
  records  = [tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[1].value]
  ttl      = 300
  zone_id  = data.aws_route53_zone.this.id
}

resource "aws_route53_record" "validation_records_linglinger_3" {
  name     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[2].name
  type     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[2].type
  records  = [tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[2].value]
  ttl      = 300
  zone_id  = data.aws_route53_zone.this.id
}

resource "aws_route53_record" "custom_domain" {
  name    = aws_apprunner_custom_domain_association.this.domain_name
  type    = "CNAME"
  records = [aws_apprunner_service.this.service_url]
  ttl     = 300
  zone_id = data.aws_route53_zone.this.id
}

At the time of writing, AWS provides 3 certificate validation records. The above exmple works with 3 certificate validation records. It also creates the record to send traffic to the app via the custom domain.

The above example enables the following custom domain for the app: custom-sub-domain.my-domain.com

holograph commented 1 year ago

I'm not sure why you'd get three separate validation records - I always get just the one? - but this is, as far as I can tell, the only way to automate this...

nemosupremo commented 1 year ago

I tried @fracampit's workaround with something like

resource "cloudflare_record" "byteman-verify" {
  count      = 3 //length(aws_apprunner_custom_domain_association.byteman.certificate_validation_records)
  zone_id    = var.CLOUDFLARE_ZONE
  name       = element(aws_apprunner_custom_domain_association.byteman.certificate_validation_records.*.name, count.index)
  value      = element(aws_apprunner_custom_domain_association.byteman.certificate_validation_records.*.value, count.index)
  type       = "CNAME"
  proxied    = false
  depends_on = [aws_apprunner_custom_domain_association.byteman]
}

but this broke when one of my app runners needed only 2 domains

Habikki commented 1 year ago

I wanted to chime in on this as I encountered this same issue today running 1.5.4 with aws provider 5.8.0.

Here's my full main.tf:

# Retrieve our data
data "aws_route53_zone" "dns_zone" {
  zone_id = var.service_dns_zone_id
}

# Declare any locals
locals {
    service_fqdn = "${var.service_host_name}.${data.aws_route53_zone.dns_zone.name}"
}

# Build our AppRunner Service
module "appruner_service" {
    source = "../apprunner"
    ecr_access_role_arn = var.ecr_access_role_arn
    service_name = var.service_name
}

# Create the custom domain name association
resource "aws_apprunner_custom_domain_association" "apprunner_service_domain_association" {
  domain_name = local.service_fqdn
  service_arn = module.appruner_service.apprunner_service_arn
}

## Add our validation Records
resource "aws_route53_record" "apprunner_service_domain_validation" {
  for_each = {
    for cvr in aws_apprunner_custom_domain_association.apprunner_service_domain_association.certificate_validation_records : cvr.name => {
      name   = cvr.name
      record = cvr.value
      type   = cvr.type
    }
  }

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

# Finally, create a record in Route53 to the new endpoint
resource "aws_route53_record" "apprunner_service_custom_domain_record" {
  name    = aws_apprunner_custom_domain_association.apprunner_service_domain_association.domain_name
  type    = "CNAME"
  records = [module.appruner_service.apprunner_service_url]
  ttl     = 300
  zone_id = data.aws_route53_zone.dns_zone.zone_id
}

Note: I sub-module out the setup of aws_apprunner_service to encapsulate some defaults I have in my projects, otherwise, it's fairly stock.

What I find pecular is that my ability to for_each works... but only when my assocation looks like:

resource "aws_apprunner_custom_domain_association" "apprunner_service_domain_association" {
  domain_name = local.service_fqdn
  service_arn = module.appruner_service.apprunner_service_arn
}

If I enable_www_subdomain = false on the aws_apprunner_custom_domain_association:

# Create the custom domain name association
resource "aws_apprunner_custom_domain_association" "apprunner_service_domain_association" {
  domain_name = local.service_fqdn
  service_arn = module.appruner_service.apprunner_service_arn
  enable_www_subdomain = false
}

Then I receive the similar error to those above.:

β”‚ Error: Invalid for_each argument
β”‚
β”‚   on ../../modules/common/apprunner_with_domain/main.tf line 27, in resource "aws_route53_record" "apprunner_service_domain_validation":
β”‚   27:   for_each = {
β”‚   28:     for cvr in aws_apprunner_custom_domain_association.apprunner_service_domain_association.certificate_validation_records : cvr.name => {
β”‚   29:       name   = cvr.name
β”‚   30:       record = cvr.value
β”‚   31:       type   = cvr.type
β”‚   32:     }
β”‚   33:   }
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚     β”‚ aws_apprunner_custom_domain_association.apprunner_service_domain_association.certificate_validation_records is a set of object, known only after apply
β”‚
β”‚ The "for_each" map includes keys derived from resource attributes that cannot be determined until apply, and so Terraform cannot determine the full set of keys that will identify the instances of this
β”‚ resource.
β”‚
β”‚ When working with unknown values in for_each, it's better to define the map keys statically in your configuration and place apply-time results only in the map values.
β”‚
β”‚ Alternatively, you could use the -target planning option to first apply only the resources that the for_each value depends on, and then apply a second time to fully converge.

Enabling or disabling www is not worth the effort for me to look into this further as without DNS resolution setup, it's an benign setting. But wanted to share my functional code, with a condition, for anyone interested.

AndreMiras commented 1 year ago

Refactored @fracampit example leveraging count.index:

resource "aws_apprunner_service" "this" {
  . . .
}

resource "aws_apprunner_custom_domain_association" "this" {
  domain_name = "custom-sub-domain.my-domain.com"
  service_arn = aws_apprunner_service.this.arn
}

data "aws_route53_zone" "this" {
  name = "my-domain.com"
}

resource "aws_route53_record" "validation_records_linglinger" {
  count = 3
  name     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[count.index].name
  type     = tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[count.index].type
  records  = [tolist(aws_apprunner_custom_domain_association.this.certificate_validation_records)[count.index].value]
  ttl      = 300
  zone_id  = data.aws_route53_zone.this.id
}

resource "aws_route53_record" "custom_domain" {
  name    = aws_apprunner_custom_domain_association.this.domain_name
  type    = "CNAME"
  records = [aws_apprunner_service.this.service_url]
  ttl     = 300
  zone_id = data.aws_route53_zone.this.id
}
ewbankkit commented 11 months ago

It looks like we will need to do something similar to https://github.com/hashicorp/terraform-provider-aws/pull/14199 and attempt to compute domain_validation_options during the plan (in a CustomizeDiff function), else it remains unknown.

em-jones commented 6 months ago

Is there an established workaround for this issue? Past the two-year mark gives me pause on whether/not I should keep app runner as a solution.