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.73k stars 9.09k forks source link

[Bug]: Terraform plan errors for known after apply values for aws_route53_record resource #36798

Open igoratencompass opened 5 months ago

igoratencompass commented 5 months ago

Terraform Core Version

1.6.6, 1.7.4

AWS Provider Version

4.67, 5.38, 5.39.1

Affected Resource(s)

aws_route53_record

Expected Behavior

I have a route53 module which has a record creating resource as part of it (of course):

resource "aws_route53_record" "record" {
  for_each = var.module_enabled ? local.records : {}

  zone_id         = each.value.zone_id
  type            = each.value.type
  name            = each.value.name
  allow_overwrite = each.value.allow_overwrite
  health_check_id = each.value.health_check_id
  set_identifier  = each.value.set_identifier

  # set default TTL when ttl missing and is not ALIAS record
  ttl = each.value.ttl == null && each.value.alias.name == null ? var.default_ttl : each.value.ttl

  [...]
}

where the local.records in the for_each loop looks like this:

+ local_records                     = {
      + "a-subdomain.domain.tld"             = {
          + alias           = {
              + evaluate_target_health = true
              + name                   = (known after apply)
              + zone_id                = (known after apply)
            }
        }
      + "cname-subdomain-api.domain.tld"     = {
          + alias           = {
              + evaluate_target_health = null
              + name                   = null
              + zone_id                = null
            }
        }

      [ more CNAMEs here ]

  + }

and the module call example:

module "dns_records" {
  source = "./modules/route53"
  count  = length(module.gla) == 1 ? 1 : 0

  zone_id = try(data.aws_route53_zone.selected.id, null)

  allow_overwrite = true

  records = flatten(concat([
    {
      name    = "${lower(var.vpc["tag"])}.${var.my_domain["name"]}"
      type    = "A"
      alias = {
        name    = module.gla[count.index].dns_name  #<== another module dependency
        zone_id = module.gla[count.index].zone_id   #<== another module dependency
        evaluate_target_health = true
      }
    },
    {
      name    = "${lower(var.vpc["tag"])}-api.${var.my_domain["name"]}"
      type    = "CNAME"
      ttl     = 60
      records = ["${lower(var.vpc["tag"])}.${var.my_domain["name"]}"]
    },
  ],
  [ for i, v in var.my_subdomain : [{
      name    = "${split("-", lower(var.vpc["tag"]))[0]}-${lower(v)}.${var.my_domain["name"]}"
      type    = "CNAME"
      ttl     = 60
      records = ["${lower(var.vpc["tag"])}.${var.my_domain["name"]}"]
    },
    {
      name    = "${split("-", lower(var.vpc["tag"]))[0]}-${lower(v)}-api.${var.my_domain["name"]}"
      type    = "CNAME"
      ttl     = 60
      records = ["${lower(var.vpc["tag"])}.${var.my_domain["name"]}"]
    }
  ]]
  ))
}

Now, this worked fine in 1.5.7 but not in latest 1.6 and 1.7 where the Route53 records fail to get created with the error bellow in the plan stage.

Actual Behavior

The error below is related to creating the ALIAS record, if I remove this from the list:

    {
      name    = "${lower(var.vpc["tag"])}.${var.my_domain["name"]}"
      type    = "A"
      alias = {
        name    = module.gla[count.index].dns_name  #<== another module dependency
        zone_id = module.gla[count.index].zone_id   #<== another module dependency
        evaluate_target_health = true
      }
    },

the error is gone. This indicates an issue with how this expression:

ttl = each.value.ttl == null && each.value.alias.name == null ? var.default_ttl : each.value.ttl

is being evaluated when the value is not known. My impression is that the effect here is as if this logic each.value.alias.name == null used to evaluate to null when name is known after apply but not anymore.

Relevant Error/Panic Output Snippet

╷
│ Error: Missing required argument
│ 
│   with module.dns_records[0].aws_route53_record.record["a-subdomain.domain.tld"],
│   on ./modules/route53/main.tf line 130, in resource "aws_route53_record" "record":
│  130:   ttl = each.value.ttl == null && each.value.alias.name == null ? var.default_ttl : each.value.ttl
│ 
│ "ttl": all of `records,ttl` must be specified
╵

Terraform Configuration Files

The modules/route53/main.tf file:

/* ROUTE53 ZONES AND RECORDS */
data "aws_caller_identity" "current" {
}

locals {
  common_tags = {
    Environment   = try(lower(var.tag), null)
    Type          = "dns"
  }

  dns_tags  = merge(
    try(var.common_tags, null),
    local.common_tags,
    try(tomap({
      "Name" = "enc-${var.common_tags["enc-environment"]}-${var.common_tags["enc-customer"]}"
    }), null)
  )
}

locals {
  zones                        = var.name == null ? [] : try(tolist(var.name), [tostring(var.name)], [])
  skip_zone_creation           = length(local.zones) == 0
  run_in_vpc                   = length(var.vpc_ids) > 0
  skip_delegation_set_creation = !var.module_enabled || local.skip_zone_creation || local.run_in_vpc ? true : var.skip_delegation_set_creation

  delegation_set_id = var.delegation_set_id != null ? var.delegation_set_id : try(
    aws_route53_delegation_set.delegation_set[0].id, null
  )
}

resource "aws_route53_delegation_set" "delegation_set" {
  count = local.skip_delegation_set_creation ? 0 : 1

  reference_name = var.reference_name

  depends_on = [var.module_depends_on]
}

resource "aws_route53_zone" "zone" {
  for_each = var.module_enabled ? toset(local.zones) : []

  name              = each.value
  comment           = var.comment
  force_destroy     = var.force_destroy
  delegation_set_id = local.delegation_set_id

  dynamic "vpc" {
    for_each = { for id in var.vpc_ids : id => id }

    content {
      vpc_id = vpc.value
    }
  }

  tags = local.dns_tags
}

locals {
  records_expanded = {
    for i, record in var.records : join("-", compact([
      lower(record.type),
      try(lower(record.set_identifier), ""),
      try(lower(record.failover), ""),
      try(lower(record.name), ""),
      ])) => {
      type = record.type
      name = try(record.name, "")
      ttl  = try(record.ttl, null)
      alias = {
        name                   = try(record.alias.name, null)
        zone_id                = try(record.alias.zone_id, null)
        evaluate_target_health = try(record.alias.evaluate_target_health, null)
      }
      allow_overwrite = try(record.allow_overwrite, var.allow_overwrite)
      health_check_id = try(record.health_check_id, null)
      idx             = i
      set_identifier  = try(record.set_identifier, null)
      weight          = try(record.weight, null)
      failover        = try(record.failover, null)
    }
  }

  records_by_name = {
    for product in setproduct(local.zones, keys(local.records_expanded)) : "${product[1]}-${product[0]}" => {
      zone_id         = try(aws_route53_zone.zone[product[0]].id, null)
      type            = local.records_expanded[product[1]].type
      name            = local.records_expanded[product[1]].name
      ttl             = local.records_expanded[product[1]].ttl
      alias           = local.records_expanded[product[1]].alias
      allow_overwrite = local.records_expanded[product[1]].allow_overwrite
      health_check_id = local.records_expanded[product[1]].health_check_id
      idx             = local.records_expanded[product[1]].idx
      set_identifier  = local.records_expanded[product[1]].set_identifier
      weight          = local.records_expanded[product[1]].weight
      failover        = local.records_expanded[product[1]].failover
    }
  }

  records_by_zone_id = {
    for id, record in local.records_expanded : id => {
      zone_id         = var.zone_id
      type            = record.type
      name            = record.name
      ttl             = record.ttl
      alias           = record.alias
      allow_overwrite = record.allow_overwrite
      health_check_id = record.health_check_id
      idx             = record.idx
      set_identifier  = record.set_identifier
      weight          = record.weight
      failover        = record.failover
    }
  }

  records = local.skip_zone_creation ? local.records_by_zone_id : local.records_by_name
}

resource "aws_route53_record" "record" {
  for_each = var.module_enabled ? local.records : {}

  zone_id         = each.value.zone_id
  type            = each.value.type
  name            = each.value.name
  allow_overwrite = each.value.allow_overwrite
  health_check_id = each.value.health_check_id
  set_identifier  = each.value.set_identifier

  # only set default TTL when not set and not alias record
  ttl = each.value.ttl == null && each.value.alias.name == null ? var.default_ttl : each.value.ttl
  #ttl = each.value.ttl == null ? var.default_ttl : each.value.ttl
  #ttl = can(each.value.ttl) && can(each.value.alias.name) ? var.default_ttl : each.value.ttl
  #ttl = lookup(each.value, "ttl", null) == null && lookup(each.value.alias, "name", null) == null ? var.default_ttl : each.value.ttl
  #ttl = try(each.value.ttl, null) == null && try(each.value.alias.name, null) == null ? var.default_ttl : each.value.ttl

  # split TXT records at 255 chars to support >255 char records
  records = can(var.records[each.value.idx].records) ? [for r in var.records[each.value.idx].records :
    each.value.type == "TXT" && length(regexall("(\\\"\\\")", r)) == 0 ?
    join("\"\"", compact(split("{SPLITHERE}", replace(r, "/(.{255})/", "$1{SPLITHERE}")))) : r
  ] : null

  dynamic "weighted_routing_policy" {
    for_each = each.value.weight == null ? [] : [each.value.weight]

    content {
      weight = weighted_routing_policy.value
    }
  }

  dynamic "failover_routing_policy" {
    for_each = each.value.failover == null ? [] : [each.value.failover]

    content {
      type = failover_routing_policy.value
    }
  }

  dynamic "alias" {
    for_each = each.value.alias.name == null ? [] : [each.value.alias]

    content {
      name                   = alias.value.name
      zone_id                = alias.value.zone_id
      evaluate_target_health = alias.value.evaluate_target_health
    }
  }
}

The modules/route53/variables.tf file:

variable "name" {
  description = "(Required) The name of the hosted zone. To create multiple zones at once, pass a list of names [\"zone1\", \"zone2\"]."
  type        = any
  default     = null
}

variable "tag" {
  description = "(Optional) VPC tag."
  type        = string
  default     = ""
}

variable "common_tags" {
  description = "(Optional) A map of tags to apply to all created resources that support tags."
  type        = map(string)
  default     = {}
}

variable "allow_overwrite" {
  description = "(Optional) Default allow_overwrite value valid for all record sets."
  type        = bool
  default     = false
}

variable "comment" {
  description = "(Optional) A comment for the hosted zone."
  type        = string
  default     = "Managed by Terraform"
}

variable "default_ttl" {
  description = "(Optional) The default TTL (Time to Live) in seconds that will be used for all records that support the ttl parameter. Will be overwritten by the records ttl parameter if set."
  type        = number
  default     = 3600
}

variable "delegation_set_id" {
  description = "(Optional) The ID of the reusable delegation set whose NS records you want to assign to the hosted zone."
  type        = string
  default     = null
}

variable "force_destroy" {
  description = "(Optional) Whether to force destroy all records (possibly managed outside of Terraform) in the zone when destroying the zone."
  type        = bool
  default     = false
}

variable "records" {
  description = "(Optional) A list of records to create in the Hosted Zone."
  type        = any
  default     = []
}

variable "reference_name" {
  description = "(Optional) The reference name used in Caller Reference (helpful for identifying single delegation set amongst others)."
  type        = string
  default     = null
}

variable "skip_delegation_set_creation" {
  description = "(Optional) Whether or not to create a delegation set and associate with the created zone."
  type        = bool
  default     = false
}

variable "vpc_ids" {
  description = "(Optional) A list of IDs of VPCs to associate with a private hosted zone. Conflicts with the delegation_set_id."
  type        = list(string)
  default     = []
}

variable "zone_id" {
  description = "(Optional) A zone ID to create the records in"
  type        = string
  default     = null
}

variable "module_enabled" {
  type        = bool
  description = "(Optional) Whether to create resources within the module or not. Default is true."
  default     = true
}

variable "module_depends_on" {
  type        = any
  description = "(Optional) A list of external resources the module depends_on. Default is []."
  default     = []
}

The modules/route53/outputs.tf file:

output "zone" {
  description = "The created Hosted Zone(s)."
  value       = try(aws_route53_zone.zone, {})
}

output "records" {
  description = "A list of all created records."
  value       = try(aws_route53_record.record, {})
}

output "delegation_set" {
  description = "The outputs of the created delegation set."
  value       = try(aws_route53_delegation_set.delegation_set[0], {})
}

# debugging purposes
output "local_records_expanded" {
  value = local.records_expanded
}
output "local_records_by_name" {
  value = local.records_by_name
}
output "local_records_by_zone_id" {
  value = local.records_by_zone_id
}
output "local_records" {
  value = local.records
}

Steps to Reproduce

You can use the module call given above to reproduce the issue:

data "aws_route53_zone" "selected" {
  name         = "${var.my_domain["name"]}."
  private_zone = false
}

module "dns_records" {
  source = "./modules/route53"
  count  = length(module.gla) == 1 ? 1 : 0

  zone_id = try(data.aws_route53_zone.selected.id, null)

  allow_overwrite = true

  records = flatten(concat([
    {
      name    = "${lower(var.vpc["tag"])}.${var.my_domain["name"]}"
      type    = "A"
      alias = {
        name    = module.gla[count.index].dns_name  #<== another module dependency
        zone_id = module.gla[count.index].zone_id   #<== another module dependency
        evaluate_target_health = true
      }
    },
    {
      name    = "${lower(var.vpc["tag"])}-api.${var.my_domain["name"]}"
      type    = "CNAME"
      ttl     = 60
      records = ["${lower(var.vpc["tag"])}.${var.my_domain["name"]}"]
    },
  ],
  [ for i, v in var.my_subdomain : [{
      name    = "${split("-", lower(var.vpc["tag"]))[0]}-${lower(v)}.${var.my_domain["name"]}"
      type    = "CNAME"
      ttl     = 60
      records = ["${lower(var.vpc["tag"])}.${var.my_domain["name"]}"]
    },
    {
      name    = "${split("-", lower(var.vpc["tag"]))[0]}-${lower(v)}-api.${var.my_domain["name"]}"
      type    = "CNAME"
      ttl     = 60
      records = ["${lower(var.vpc["tag"])}.${var.my_domain["name"]}"]
    }
  ]]
  ))
}

with variables file variables.tf looking something like this:

variable "vpc" {
  type = map(string)
  default = {
    tag = "myenv-au-test"
  }
}

variable "my_domain" {
  type = map(string)
  default = {
    name = "mydomain.com"
  }
}

variable "my_subdomain" {
  type = list(string)
  default = ["subdomain1", "subdomain2"]
}

Where you will need a valid Route53 Zone for the data.aws_route53_zone and another module to simulate the module.gla's module.gla[count.index].dns_name and module.gla[count.index].zone_id outputs that the module.dns_records depends on and are unknown at the plan stage.

Debug Output

No response

Panic Output

No response

Important Factoids

No response

References

The link to Discuss https://discuss.hashicorp.com/t/terraform-plan-errors-for-known-after-apply-values/63457 where @apparentlymart and @jbardin generously offered their help.

Would you like to implement a fix?

None

github-actions[bot] commented 5 months ago

Community Note

Voting for Prioritization

Volunteering to Work on This Issue