Implicit dependency between modules with for_each

Hello,

We just observed a strange behaviour when tried to split the code into modules.

It is a simple, four steps ,task:

  1. Create certificate
  2. Create DNS zone
  3. Create certificate validation records
  4. Validate certificate

We know that some additional steps are required to make it works in real life scenario.

1. Code works as expected when is located in a single file

main-single.tf
# // Variables
variable "domain_name" {
  type    = string
  default = "domain.com"
}

# // Request certificate
resource "aws_acm_certificate" "common" {
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  validation_method         = "DNS"

  options {
    certificate_transparency_logging_preference = "DISABLED"
  }
}

# // Validate certificate
resource "aws_acm_certificate_validation" "common" {
  certificate_arn         = aws_acm_certificate.common.arn
  validation_record_fqdns = [for record in aws_route53_record.acm_certificate_validation : record.fqdn]
}

# // DNS zone
resource "aws_route53_zone" "common" {
  name    = var.domain_name
  comment = "Terraform"
}

# // DNS records - ACM validation
resource "aws_route53_record" "acm_certificate_validation" {
  for_each = {
    for dvo in aws_acm_certificate.common.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

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

2 . And it still works when we move just part of the code in one module

β”œβ”€β”€ main-one-module.tf
└── modules
    └── acm
        └── main.tf
main-one-modules.tf
# // Variables
variable "domain_name" {
  type    = string
  default = "domain.com"
}

# // ACM
module "acm" {
  source = "./modules/acm"

  domain_name                    = var.domain_name
  certificate_validation_records = aws_route53_record.acm_certificate_validation
}

# // DNS zone
resource "aws_route53_zone" "common" {
  name    = var.domain_name
  comment = "Terraform"
}

# // DNS records - ACM validation
resource "aws_route53_record" "acm_certificate_validation" {
  for_each = {
    for dvo in module.acm.certificate_domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = aws_route53_zone.common.zone_id
}
main-acm.tf
# // Variables
variable "domain_name" {
  type = string
}

variable "certificate_validation_records" {
  type = map(any)
}


# // Request certificate
resource "aws_acm_certificate" "common" {
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  validation_method         = "DNS"

  options {
    certificate_transparency_logging_preference = "DISABLED"
  }
}

# // Validate certificate
resource "aws_acm_certificate_validation" "common" {
  certificate_arn         = aws_acm_certificate.common.arn
  validation_record_fqdns = [for record in var.certificate_validation_records : record.fqdn]
}


# // Output
output "certificate_domain_validation_options" {
  value = aws_acm_certificate.common.domain_validation_options
}

3. But when we break it down into two modules, we got an error

β”œβ”€β”€ main-two-modules.tf
└── modules
    β”œβ”€β”€ acm
    β”‚   └── main-acm.tf
    └── route53
        └── main-route53.tf

main-two-modules.tf
# // Variables
variable "domain_name" {
  type    = string
  default = "domain.com"
}

# // ACM
module "acm" {
  source = "./modules/acm"

  domain_name                    = var.domain_name
  certificate_validation_records = module.route53.acm_certificate_validation
}

# // Route53
module "route53" {
  source = "./modules/route53"

  domain_name                           = var.domain_name
  certificate_domain_validation_options = module.acm.certificate_domain_validation_options
}
main-acm.tf
# // Variables
variable "domain_name" {
  type = string
}

variable "certificate_validation_records" {
  type = map(any)
}


# // Request certificate
resource "aws_acm_certificate" "common" {
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  validation_method         = "DNS"

  options {
    certificate_transparency_logging_preference = "DISABLED"
  }
}

# // Validate certificate
resource "aws_acm_certificate_validation" "common" {
  certificate_arn         = aws_acm_certificate.common.arn
  validation_record_fqdns = [for record in var.certificate_validation_records : record.fqdn]
}


# // Output
output "certificate_domain_validation_options" {
  value = aws_acm_certificate.common.domain_validation_options
}
main-route53.tf
# // Variables
variable "domain_name" {
  type = string
}

variable "certificate_domain_validation_options" {
  type = list(any)
}


# // DNS zone
resource "aws_route53_zone" "common" {
  name    = var.domain_name
  comment = "Terraform"
}

# // DNS records - ACM validation
resource "aws_route53_record" "acm_certificate_validation" {
  for_each = {
    for dvo in var.certificate_domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

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


# // Outputs
output "acm_certificate_validation" {
  value = aws_route53_record.acm_certificate_validation
}

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.

What is the difference when Terraform determine resources it should create, when we use flat structure and modules?

Also, we found an open issue on GitHub, but not clear if it is related - for_each not working well when creating aws_route53_record #14447.

Hi @stmx38,

I’m afraid I’m not sure yet exactly what is causing this difference in behavior. One significant difference when you pass values through input variables into another module is that Terraform will perform a type conversion to make the value match the specified type constraint, and so one possible cause is that the type conversion is relying on unknown information and therefore producing an unknown value as its result.

You’ve declared certificate_domain_validation_options as list(any), which is not an exact type constraint. I would suggest making all of your type constraints be exact (no use of any) so that Terraform will have more information and not need to guess what you intended.

The documentation for aws_acm_certificate says that the domain_validation_options attribute is a set of objects rather than a list, and so I think the following is the correct type constraint:

variable "certificate_domain_validation_options" {
  type = set(object({
    domain_name           = string
    resource_record_name  = string
    resource_record_type  = string
    resource_record_value = string
  }))
}

I suggest starting by using the type constraint I showed above, and see if this makes any difference to Terraform’s behavior. I’m not sure if this will fully solve the problem, but if this changes the error messages from Terraform in any way please tell me the new messages and then we can think about what to try next.

1 Like

@apparentlymart, thank you for the fast reply and clarification!

Looks like your assumption was correct and type changing solved the issue. Now, plan looks good and we can go to the next steps :slight_smile:

Plan: 5 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.