Terraform: Creating and validating multiple ACM certificates - amazon-web-services

I'm running into a really confusing Terraform resource issue automating the generation and DNS validation of SSL certificates in ACM for a list of (Terraform-managed) hosted zones. Code can also be found in this gist.
I'm starting by bootstrapping hosted zones referencing this environment-specific variable.
hosted_zones = [
{
domain = "site1.com"
zone_id = "MANUALLY FILL"
}
]
The block I am using to build the zones seems to work reliably.
resource "aws_route53_zone" "zones" {
count = "${length(var.hosted_zones)}"
name = "${lookup(var.hosted_zones[count.index], "domain")}"
}
After the zones are built, I am manually copying the zone ID into the variable because I haven't come up with a clever way to automate it given a combination of limitations of HCL and my lack of experience with it.
I can reliably generate naked and splat certificates for each hosted zone using...
resource "aws_acm_certificate" "cert" {
count = "${length(var.hosted_zones)}"
domain_name = "${lookup(var.hosted_zones[count.index], "domain")}"
subject_alternative_names = ["*.${lookup(var.hosted_zones[count.index], "domain")}"]
validation_method = "DNS"
tags {
Project = "${var.project}"
Environment = "${var.environment}"
}
}
Where things get hairy is when I try to automate the DNS validation for the certs. There is a good example in the documentation for a single hosted zone, but I haven't been able to successfully port it to multiple hosted-zones. My attempt...
resource "aws_route53_record" "cert_validation" {
count = "${length(var.hosted_zones)}"
name = "${aws_acm_certificate.cert.*.domain_validation_options.0.resource_record_name[count.index]}"
type = "${aws_acm_certificate.cert.*.domain_validation_options.0.resource_record_type[count.index]}"
zone_id = "${var.zone_override != "" ? var.zone_override : lookup(var.hosted_zones[count.index], "zone_id")}"
records = ["${aws_acm_certificate.cert.*.domain_validation_options.0.resource_record_value[count.index]}"]
ttl = 60
}
resource "aws_acm_certificate_validation" "cert" {
count = "${length(var.hosted_zones)}"
certificate_arn = "${aws_acm_certificate.cert.*.arn[count.index]}"
validation_record_fqdns = ["${aws_route53_record.cert_validation.*.fqdn[count.index]}"]
}
The error I am seeing on first run is:
* module.acm.aws_route53_record.cert_validation: 1 error(s) occurred:
* module.acm.aws_route53_record.cert_validation: Resource 'aws_acm_certificate.cert' does not have attribute 'domain_validation_options.0.resource_record_value' for variable 'aws_acm_certificate.cert.*.domain_validation_options.0.resource_record_value'
The obnoxious part is that if I comment the validation resources, the apply succeeds, and then uncommenting them and re-running also succeeds.
I've tried (what feels like) every permutation of element() lookup(), list() and map() to target certificates by index in the output from the first resource block, but am running into documented "flat list" limitations and this is the closest I've gotten to success. I'd like to understand why the workaround is necessary so I can eliminate it. This feels like a syntax issue or me trying to get HCL to behave more like an OO language than it is.
Thank you for any experience that may help!

I had a similar scenario and the key to solving it was the use of locals and flatten(). The approach should also work for you such that you shouldn't need two passes to create the resources.
In this scenario there are multiple domains that each have subdomains that will appear in the subjectAltName section of the certificate. For example:
├── preview.example.com
│ ├── app.preview.example.com
│ └── www.preview.example.com
├── demo.example.com
│ ├── app.demo.example.com
│ └── www.demo.example.com
├── staging.example.com
│ ├── app.staging.example.com
│ └── www.staging.example.com
└── example.com
├── app.example.com
└── www.example.com
To achieve this we first set some variables:
variable "domains" {
type = "list"
default = [
"demo.example.com",
"preview.example.com",
"staging.example.com",
"example.com"
]
}
variable "subdomains" {
type = "list"
default = [
"app",
"www"
]
}
Next we create the certificate resources that contain the subdomains as SANs.
resource "aws_acm_certificate" "cert" {
count = "${length(var.domains)}"
domain_name = "${element(var.domains, count.index)}"
validation_method = "DNS"
subject_alternative_names = ["${
formatlist("%s.%s",
var.subdomains,
element(var.domains, count.index)
)
}"]
}
Next we're going to need a local variable to flatten the resulting set of domains and subdomains.
This is needed because terraform doesn't support nested list syntax as of version 0.11.7, neither
via the element() interpolation nor the `list[count].
locals {
dvo = "${flatten(aws_acm_certificate.cert.*.domain_validation_options)}"
}
We'll next need a lookup of the Route 53 zone that we can use in the subsequent Route 53 records:
data "aws_route53_zone" "zone" {
count = "${length(var.domains) > 0 ? 1 : 0}"
name = "example.com."
private_zone = false
}
We then create the Route 53 DNS records that will be populated with data from the certificate
resource for DNS validation. We're adding one to the subdomains so that we also have a
record for the base domain not included in the list of subdomains.
resource "aws_route53_record" "cert_validation" {
count = "${length(var.domains) * (length(var.subdomains) + 1)}"
zone_id = "${data.aws_route53_zone.zone.id}"
ttl = 60
name = "${lookup(local.dvo[count.index], "resource_record_name")}"
type = "${lookup(local.dvo[count.index], "resource_record_type")}"
records = ["${lookup(local.dvo[count.index], "resource_record_value")}"]
}
Finally we create the certificate validation resource that will wait for the certificate to be
issued.
resource "aws_acm_certificate_validation" "cert" {
count = "${length(var.domains) * (length(var.subdomains) + 1)}"
certificate_arn = "${element(aws_acm_certificate.cert.*.arn, count.index)}"
validation_record_fqdns = ["${aws_route53_record.cert_validation.*.fqdn}"]
}
The one caveat for this last resource is that it'll create one instance of the resource for every
certificate requested, but each instance will depend on all the FQDNs across all domains and
subdomains. This won't affect anything in AWS but the terraform code won't continue/complete
until all certs are issued.
This should work in a single apply run with no need to -target any resources in a first pass,
though there is an apparently known issue around how long it takes for the validations to
complete when
performed via terraform, and for this reason it may require a second pass, albeit without changing the code or plan/apply invocation.

So after a bit of experimenting, I ended up leveraging -target=aws_acm_certificate.cert as a workaround to avoid the missing attribute errors I was seeing. The syntax I was using above was correct, and the error was a result of the apply needing to complete for the certificate before the validation steps could reference the generated attributes.
In addition, I found an elegant solution for the MANUAL FILL step using zipmap. The result looks like this...
Variable:
hosted_zones = [
"foo.com"
]
Output from hosted_zones module:
output "hosted_zone_ids" {
value = "${zipmap(var.hosted_zones, aws_route53_zone.zones.*.zone_id)}"
}
Then, my certificate generation/validation module looks like this, where var.hosted_zone_map is the output of the previous zipmap that creates a map of hosted zone domain name to assigned zone ID:
resource "aws_acm_certificate" "cert" {
count = "${length(keys(var.hosted_zone_map))}"
domain_name = "${element(keys(var.hosted_zone_map), count.index)}"
subject_alternative_names = ["*.${element(keys(var.hosted_zone_map), count.index)}"]
validation_method = "DNS"
tags {
Project = "${var.project}"
Environment = "${var.environment}"
}
}
resource "aws_route53_record" "cert_validation" {
count = "${length(keys(var.hosted_zone_map))}"
zone_id = "${lookup(var.hosted_zone_map, element(keys(var.hosted_zone_map), count.index))}"
name = "${aws_acm_certificate.cert.*.domain_validation_options.0.resource_record_name[count.index]}"
type = "${aws_acm_certificate.cert.*.domain_validation_options.0.resource_record_type[count.index]}"
records = ["${aws_acm_certificate.cert.*.domain_validation_options.0.resource_record_value[count.index]}"]
ttl = 60
}
resource "aws_acm_certificate_validation" "cert" {
count = "${length(keys(var.hosted_zone_map))}"
certificate_arn = "${aws_acm_certificate.cert.*.arn[count.index]}"
validation_record_fqdns = ["${aws_route53_record.cert_validation.*.fqdn[count.index]}"]
}
The positioning of the splat was definitely the trickiest and least documented part of tracking this down, so hopefully this helps someone else out.

I know this question is old, but for anyone searching for answers today, Terraform's updated documentation for the AWS provider has a great example for this using a for_each loop. This applies to terraform 1.x, obviously, but maybe other recent Check out https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validation#dns-validation-with-route-53

Related

Terraform Provider issue: registry.terraform.io/hashicorp/s3

I current have code that I have been using for quiet sometime that calls a custom S3 module. Today I tried to run the same code and I started getting an error regarding the provider.
╷ │ Error: Failed to query available provider packages │ │ Could not
retrieve the list of available versions for provider hashicorp/s3:
provider registry registry.terraform.io does not have a provider named
│ registry.terraform.io/hashicorp/s3 │ │ All modules should specify
their required_providers so that external consumers will get the
correct providers when using a module. To see which modules │ are
currently depending on hashicorp/s3, run the following command: │
terraform providers
Doing some digging seems that terraform is looking for a module registry.terraform.io/hashicorp/s3, which doesn't exist.
So far, I have tried the following things:
Validated that the S3 Resource code meets the standards of the upgrade Hashicorp did to 4.x this year. Plus I have been using it for a couple of months with no issues.
Delete .terraform directory and rerun terraform init (No success same error)
Delete .terraform directory and .terraform.hcl lock and run terraform init -upgrade (No Success)
I have tried to update my provider's file to try to force an upgrade (no Success)
I tried to change the provider to >= current version to pull the latest version with no success
Reading further, it refers to a caching problem of the terraform modules. I tried to run terraform providers lock and received this error.
Error: Could not retrieve providers for locking │ │ Terraform failed
to fetch the requested providers for darwin_amd64 in order to
calculate their checksums: some providers could not be installed: │ -
registry.terraform.io/hashicorp/s3: provider registry
registry.terraform.io does not have a provider named
registry.terraform.io/hashicorp/s3.
Kind of at my wits with what could be wrong. below is a copy of my version.tf which I changed from providers.tf based on another post I was following:
version.tf
# Configure the AWS Provider
provider "aws" {
region = "us-east-1"
use_fips_endpoint = true
}
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.9.0"
}
local = {
source = "hashicorp/local"
version = "~> 2.2.1"
}
}
required_version = ">= 1.2.0" #required terraform version
}
S3 Module
I did not include locals, outputs, or variables unless someone thinks we need to see them. As I said before, the module was running correctly until today. Hopefully, this is all you need for the provider's issue. Let me know if other files are needed.
resource "aws_s3_bucket" "buckets" {
count = length(var.bucket_names)
bucket = lower(replace(replace("${var.bucket_names[count.index]}-s3", " ", "-"), "_", "-"))
force_destroy = var.bucket_destroy
tags = local.all_tags
}
# Set Public Access Block for each bucket
resource "aws_s3_bucket_public_access_block" "bucket_public_access_block" {
count = length(var.bucket_names)
bucket = aws_s3_bucket.buckets[count.index].id
block_public_acls = var.bucket_block_public_acls
ignore_public_acls = var.bucket_ignore_public_acls
block_public_policy = var.bucket_block_public_policy
restrict_public_buckets = var.bucket_restrict_public_buckets
}
resource "aws_s3_bucket_acl" "bucket_acl" {
count = length(var.bucket_names)
bucket = aws_s3_bucket.buckets[count.index].id
acl = var.bucket_acl
}
resource "aws_s3_bucket_versioning" "bucket_versioning" {
count = length(var.bucket_names)
bucket = aws_s3_bucket.buckets[count.index].id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "bucket_lifecycle_rule" {
count = length(var.bucket_names)
bucket = aws_s3_bucket.buckets[count.index].id
rule {
id = "${var.bucket_names[count.index]}-lifecycle-${count.index}"
status = "Enabled"
expiration {
days = var.bucket_backup_expiration_days
}
transition {
days = var.bucket_backup_days
storage_class = "GLACIER"
}
}
}
# AWS KMS Key Server Encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "bucket_encryption" {
count = length(var.bucket_names)
bucket = aws_s3_bucket.buckets[count.index].id
rule {
apply_server_side_encryption_by_default {
kms_master_key_id = aws_kms_key.bucket_key[count.index].arn
sse_algorithm = var.bucket_sse
}
}
}
Looking for any other ideas I can use to fix this issue. thank you!!
Although you haven't included it in your question, I'm guessing that somewhere else in this Terraform module you have a block like this:
resource "s3_bucket" "example" {
}
For backward compatibility with modules written for older versions of Terraform, terraform init has some heuristics to guess what provider was intended whenever it encounters a resource that doesn't belong to one of the providers in the module's required_providers block. By default, a resource "belongs to" a provider by matching the prefix of its resource type name -- s3 in this case -- to the local names chosen in the required_providers block.
Given a resource block like the above, terraform init would notice that required_providers doesn't have an entry s3 = { ... } and so will guess that this is an older module trying to use a hypothetical legacy official provider called "s3" (which would now be called hashicorp/s3, because official providers always belong to the hashicorp/ namespace).
The correct name for this resource type is aws_s3_bucket, and so it's important to include the aws_ prefix when you declare a resource of this type:
resource "aws_s3_bucket" "example" {
}
This resource is now by default associated with the provider local name "aws", which does match one of the entries in your required_providers block and so terraform init will see that you intend to use hashicorp/aws to handle this resource.
My colleague and I finally found the problem. Turns out that we had a data call to the S3 bucket. Nothing was wrong with the module but the place I was calling the module had a local.tf action where I was calling s3 in a legacy format see the change below:
WAS
data "s3_bucket" "MyResource" {}
TO
data "aws_s3_bucket" "MyResource" {}
Appreciate the responses from everyone. Resource was the root of the problem but forgot that data is also a resource to check.

Terraform Inappropriate value for attribute "route"

relatively new to terraform and currently trying to build cloud infrastructure in AWS.
I get an error when I use an official example (little bit changed) from the documentation for the resource aws_route_table (https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table)
resource "aws_route_table" "prod-route-table" {
vpc_id = aws_vpc.prod-vpc.id
route = [{
# Route all Traffic to the internet gateway
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
},{
ipv6_cidr_block = "::/0"
gateway_id = aws_internet_gateway.gw.id
}]
}
I get the following error message
Error: Incorrect attribute value type
│ Inappropriate value for attribute "route": element 0: attributes "carrier_gateway_id",
│ "destination_prefix_list_id", "egress_only_gateway_id", "instance_id", "ipv6_cidr_block",
│ "local_gateway_id", "nat_gateway_id", "network_interface_id", "transit_gateway_id", "vpc_endpoint_id",
│ and "vpc_peering_connection_id" are required.
Adding all these attributes solves the error however this blows up the code massively.
Writing it differently (see the following) results in no errors, is the terraform AWS documentation incorrect, as they clearly state the first way of writing it?
resource "aws_route_table" "prod-route-table" {
vpc_id = aws_vpc.prod-vpc.id
route {
# Route all Traffic to the internet gateway
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
route{
ipv6_cidr_block = "::/0"
gateway_id = aws_internet_gateway.gw.id
}
}
I am using terraform v1.0.10 and aws provider version = "3.63.0"
Thanks in advance
The documentation for that argument mentions that it's using the legacy attributes as blocks mode, which is a holdover from Terraform v0.12 for some situations where providers were depending on being able to write certain arguments in both the nested block syntax (like in your second example) and in the attribute syntax (like in your first example).
The syntax currently shown in the doc example -- and your first example in your question -- is contrary to the advice about how to write a fixed value (as opposed to a dynamic value), so indeed the second example you showed here would be the preferred way as far as the general Terraform documentation is concerned.
resource "aws_route_table" "example" {
vpc_id = aws_vpc.example.id
route {
cidr_block = "10.0.1.0/24"
gateway_id = aws_internet_gateway.example.id
}
route {
ipv6_cidr_block = "::/0"
egress_only_gateway_id = aws_egress_only_internet_gateway.example.id
}
tags = {
Name = "example"
}
}
Possibly the AWS provider documentation author here used the attribute syntax in order to show symmetry with the special case of setting route = [] to explicitly state that there should be no routes at all, because entirely omitting this argument unfortunately (for historical reasons) means to ignore any existing routes in the remote API, rather than to remove all existing routes in the remote API.
There's a little more about the behavior you saw in the subsequent section arbitrary expressions with argument syntax:
Because of the rule that argument declarations like this fully override any default value, when creating a list-of-objects expression directly the usual handling of optional arguments does not apply, so all of the arguments must be assigned a value, even if it's an explicit null:
example = [
{
# Cannot omit foo in this case, even though it would be optional in the
# nested block syntax.
foo = null
},
]
Over time providers will gradually phase out this legacy mode, but must do so cautiously because it can be a breaking change for some existing configurations. Until then this is unfortunately a confusing rough edge for some particular provider attributes, though they should at least all link to the relevant documentation page I linked above to note that their behavior doesn't match the normal Terraform argument-handling behaviors.

How in Terraform to get a LB name created in another module (for a DNS records' creation)

I'm creating a DNS record AWS using Terraform (v. 12.10) and want to get the name of the ALB, which already was created (in another module).
I've read the documentation but didn't found any solution. Is there any way to do it?
resource "aws_route53_record" "dns" {
provider = <AWS>
zone_id = <ZONE_ID>
name = <NAME>
ttl = 30
type = "CNAME"
records = <LB_created_previously>
}
You basically have two options here.
Option 1 - if your resource creation (in your case the DNS records) and the ALB created by the module are in the same place (same terraform.tfstate file) - this is more or less covered by samtoddler answer above or with you pseudo-code it will look something like this:
resource "aws_route53_record" "dns" {
provider = <AWS>
zone_id = <ZONE_ID>
name = <NAME>
ttl = 30
type = "CNAME"
records = [module.<LB__module_definiton_name>.elb_dns_name]
}
where in your ELB module you would need something like:
output "elb_dns_name" {
value = aws_elb.<LB_created_previously>.dns_name
}
In option Two, you must have the same output defined in the module itself.
However, if your DNS resource code is in a different folder / terraform state, you'll need to resort to a terraform remote state:
data "terraform_remote_state" "elb" {
backend = "mybackendtype"
config = {
...
}
}
And then you code will look like this:
resource "aws_route53_record" "dns" {
provider = <AWS>
zone_id = <ZONE_ID>
name = <NAME>
ttl = 30
type = "CNAME"
records = [data.terraform_remote_state.elb.outputs.elb_dns_name]
}
Btw, when you have an ELB it's better to use an Alias instead of a CNAME record, which based on the terraform documentation for the dns records resource and your pseudo-code will be:
resource "aws_route53_record" "dns" {
zone_id = <ZONE_ID>
name = <NAME>
type = "A"
alias {
name = module.<LB__module_definiton_name>.elb_dns_name
zone_id = module.<LB__module_definiton_name>.elb_zone_id
evaluate_target_health = true
}
}
Module definition
$ cat module/out.tf
output "somevar" {
value = "somevalue"
}
Using Module:
$ cat main.tf
module "getname" {
source = "./module"
}
resource "aws_sns_topic" "user_updates" {
name = module.getname.somevar
}
Directory structure:
$ tree
.
├── main.tf
├── module
│   └── out.tf
└── terraform.tfstate
terraform apply
$ terraform apply
..
+ create
Terraform will perform the following actions:
# aws_sns_topic.user_updates will be created
+ resource "aws_sns_topic" "user_updates" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "somevalue"
+ policy = (known after apply)
}
...
Enter a value: yes
aws_sns_topic.user_updates: Creating...
aws_sns_topic.user_updates: Creation complete after 1s [id=arn:aws:sns:us-east-1:123456789:somevalue]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Module Composition

Setting "count" based on the length of an attribute on another resource

I have a fairly simple Terraform configuration, which creates a Route53 zone and then creates NS records in Cloudflare to delegate the subdomain to that zone. At present, it assumes there's always exactly four authoritative DNS servers for every Route53 zone, and creates four separate cloudflare_record resources, but I'd like to generalise that, partially because who knows if AWS will start putting a fifth authoritative server out there in the future, but also as a "test case" for more complicated stuff in the future (like AWS AZs, which I know vary in count between regions).
What I've come up with so far is:
resource "cloudflare_record" "public-zone-ns" {
domain = "example.com"
name = "${terraform.env}"
type = "NS"
ttl = "120"
count = "${length(aws_route53_zone.public-zone.name_servers)}"
value = "${lookup(aws_route53_zone.public-zone.name_servers, count.index)}"
}
resource "aws_route53_zone" "public-zone" {
name = "${terraform.env}.example.com"
}
When I run terraform plan over this, though, I get this error:
Error running plan: 1 error(s) occurred:
* cloudflare_record.public-zone-ns: cloudflare_record.public-zone-ns: value of 'count' cannot be computed
I think what that means is that because the aws_route53_zone hasn't actually be created, terraform doesn't know what length(aws_route53_zone.public-zone.name_servers) is, and therefore the interpolation into cloudflare_record.public-zone-ns.count fails and I'm screwed.
However, it seems surprising to me that Terraform would be so inflexible; surely being able to create a variable number of resources like this would be meat-and-potatoes stuff. Hard-coding the length, or creating separate resources, just seems so... limiting.
So, what am I missing? How can I create a number of resources when I don't know in advance how many I need?
Currently count not being able to be calculated is an open issue in terraform https://github.com/hashicorp/terraform/issues/12570
You could move the name servers to a variable array and then get the length of that, all in one terraform script.
I got your point, this surprised me as well. Even I added depends_on to resource cloudflare_record, it is helpless.
What you can do to pass this issue is to split it into two stacks and make sure the route 53 record is created before cloudflare record.
Stack #1
resource "aws_route53_zone" "public-zone" {
name = "${terraform.env}.example.com"
}
output "name_servers" {
value = "${aws_route53_zone.public-zone.name_servers}"
}
Stack #2
data "terraform_remote_state" "route53" {
backend = "s3"
config {
bucket = "terraform-state-prod"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "cloudflare_record" "public-zone-ns" {
domain = "example.com"
name = "${terraform.env}"
type = "NS"
ttl = "120"
count = "${length(data.terraform_remote_state.route53.name_servers)}"
value = "${element(data.terraform_remote_state.route53.name_servers, count.index)}"
}

How can i specify the dns servers when terrafrorm uses aws_route53_zone

When terraform runs the following, it apparently picks random NS servers:
resource "aws_route53_zone" "example.com" {
name = "example.com"
}
The problem with this is that the registered domain that I have in AWS already has specified NS servers. Is there a way to specify the NS servers this resource uses - or maybe change the hosted domain's NS servers to what is picked when the zone is created?
When you create a new zone, AWS generates the Name server list for you. Using this example from Terraform.
resource "aws_route53_zone" "dev" {
name = "dev.example.com"
tags {
Environment = "dev"
}
}
resource "aws_route53_record" "dev-ns" {
zone_id = "${aws_route53_zone.main.zone_id}"
name = "dev.example.com"
type = "NS"
ttl = "30"
records = [
"${aws_route53_zone.dev.name_servers.0}",
"${aws_route53_zone.dev.name_servers.1}",
"${aws_route53_zone.dev.name_servers.2}",
"${aws_route53_zone.dev.name_servers.3}",
]
}
https://www.terraform.io/docs/providers/aws/r/route53_zone.html
API returns a Delegation Set after the call to Create Zone.
http://docs.aws.amazon.com/Route53/latest/APIReference/API_CreateHostedZone.html#API_CreateHostedZone_ResponseSyntax
I have been able to specify DNS servers but I would imagine that AWS is allocating servers based on availability, load etc... so you may want to think hard about baking these configs in.
resource "aws_route53_record" "primary-ns" {
zone_id = "${aws_route53_zone.primary.zone_id}"
name = "www.bacon.rocks"
type = "NS"
ttl = "172800"
records = ["ns-869.awsdns-44.net","ns-1237.awsdns-26.org","ns-1846.awsdns-38.co.uk","ns-325.awsdns-40.com"]
}
or something along those lines