Using terraform's for_each with data that doesn't have a unique key - terraform

When using terraform's for_each you have to specify a unique id to be used as a way to link the generated recource with its source definition.
I'd like to use a natural index for this, rather than an arbitrary unique value. In this case I'm working with DNS, so the natural index would be the DNS record name (FQDN)... Only that isn't always unique; i.e. you can have multipe A records for example.com to allow load balancing, or you may have multiple TXT records for providing verification to multiple vendors.
Is there a way to combine the natural index with a calculated value to provide a unique value; e.g. so we have the natural index followed by a 1 if it's the first time this value's seen, a 2 for the first duplicate, etc?
Specific Requirement / Context
I'm working on migrating our DNS records to be managed via IaC using Terraform/Terragrunt (this is for scenarios where the entries are manually managed, rather than those where the related service is also under IaC).
I'm hoping to hold the record data in CSVs (or similar) to avoid those managing the records day to day from requiring familiarity with TF/TG; instead allowing them to just update the data, and have the pipeline take care of the rest.
The CSV format would be something like this:
myId
RecordName
Type
Value
1
A
1.2.3.4
2
A
2.3.4.5
3
test
A
3.4.5.6
4
test
A
4.5.6.7
5
www
cname
example.com
Note: I'm considering each DNS Zone would have a folder with its name, and a CSV formatted as above which gives the records for that zone; so the above would be in the /example.com/ folder, and thus we'd have 2 A records for example.com, 2 for test.example.com and one CName for www.example.com which pointed to example.com.
locals {
instances = csvdecode(file("myDnsRecords.csv"))
}
resource aws_route53_zone zone {
name = var.domainname
provider = aws
}
resource aws_route53_record route53_entry {
for_each = {for inst in local.instances : inst.myId => inst}
name = "${each.value.RecordName}${each.value.RecordName == "" ? "" : "."}${var.domainname}"
type = each.value.Type
zone_id = aws_route53_zone.zone.zone_id
ttl = 3600
records = [each.value.Value]
}
I don't want the myId column though; as that doesn't add value / has no real relationship to the records; so if we were to remove/insert a record early in the CSV and renumber the following records it would result in a number of changes being required to records which hadn't really changed, just because their related "index" had changed.
I also don't want those working with these CSVs to have to manually manage such fields; i.e. I could provide another column and ask that they populate this as below... but that's asking for human error and adding complexity:
myId
RecordName
Type
Value
1
A
1.2.3.4
2
A
2.3.4.5
test1
test
A
3.4.5.6
test2
test
A
4.5.6.7
www1
www
cname
example.com
Question
Is there a way I can use a for_each loop with CSV data such as below, whilst working around the unique constraint?
RecordName
Type
Value
A
1.2.3.4
A
2.3.4.5
test
A
3.4.5.6
test
A
4.5.6.7
www
cname
example.com

You can add unique keys to the data structure:
locals {
instances = csvdecode(file("myDnsRecords.csv"))
instance_map = zipmap(range(0,length(local.instances)), local.instances)
}
resource "..." "..." {
for_each = local.instance_map
...
}

Terraform's for expressions when constructing a mapping have a "grouping mode" where it allows duplicate keys in return for the values of the map all being lists of potentially-multiple values that all had the same key.
I would therefore start by using that to project the CSV data into a map(list(map(string))) value where the keys are build from the record name and type, like this:
locals {
records_raw = csvdecode(file("${path.module}/myDnsRecords.csv"))
records_grouped = tomap({
for row in local.records_raw :
"${row.RecordName} ${row.RecordType}" => row...
})
}
The resulting data structure would be shaped like this:
records_grouped = tomap({
" A" = tolist([
{ RecordName = "", Type = "A", Value = "1.2.3.4" },
{ RecordName = "", Type = "A", Value = "2.3.4.5" },
])
"test A" = tolist([
{ RecordName = "test", Type = "A", Value = "3.4.5.6" },
{ RecordName = "test", Type = "A", Value = "4.5.6.7" },
])
"www CNAME" = tolist([
{ RecordName = "www", Type = "CNAME", Value = "example.com" },
])
})
Collecting the records with common keys into lists means that we now have a list index for each one that's unique only within the records with the common key.
So now we can project this one more time into a flat map of maps (map(map(string))) by incorporating those list indices into the map keys:
locals {
records = tomap(merge([
for group_key, group in local.records_grouped : {
for idx, record in group :
"${group_key} ${idx}" => group
}
]...))
}
This should produce a data structure like the following:
records = tomap({
" A 0" = { RecordName = "", Type = "A", Value = "1.2.3.4" }
" A 1" = { RecordName = "", Type = "A", Value = "2.3.4.5" }
"test A 0" = { RecordName = "test", Type = "A", Value = "3.4.5.6" }
"test A 1" = { RecordName = "test", Type = "A", Value = "4.5.6.7" }
"www CNAME 0" = { RecordName = "www", Type = "CNAME", Value = "example.com" }
})
That data structure is suitably-shaped for a for_each expression, so finally:
resource "aws_route53_record" "example" {
for_each = local.records
name = "${each.value.RecordName}${each.value.RecordName == "" ? "" : "."}${var.domainname}"
type = each.value.Type
zone_id = aws_route53_zone.zone.zone_id
ttl = 3600
records = [each.value.Value]
}
This will produce instance unique instance keys for each entry in the source CSV file while keeping all of the distinct (name, type) pairs separated so that you can add new ones without disturbing any existing records:
aws_route53_record.example[" A 0"]
aws_route53_record.example[" A 1"]
aws_route53_record.example["test A 0"]
...etc
You mentioned wanting a separate instance for each row in your CSV file but I also wanted to note that the aws_route53_record resource type is already designed to manage multiple records with the same name and type together, and so I think it would actually be fine to leave the records grouped together. (The name `aws_route53_record is a bit of a misnomer because each instance of this resource type manages a record set, not just a single record.)
Here's a variation that works that way:
locals {
records_raw = csvdecode(file("${path.module}/myDnsRecords.csv"))
record_groups = tomap({
for row in local.records_raw :
"${row.RecordName} ${row.RecordType}" => row...
})
recordsets = tomap({
for group_key, group in local.record_groups : group_key => {
name = group[0].Name
type = group[0].Type
values = group[*].Value
}
})
}
resource "aws_route53_record" "example" {
for_each = local.recordsets
name = "${each.value.name}${each.value.name == "" ? "" : "."}${var.domainname}"
type = each.value.type
zone_id = aws_route53_zone.zone.zone_id
ttl = 3600
records = each.value.values
}
This time the final map has one element per recordset instead of one element per record, after grouping all of the individual records together using their names and types. Now you don't need any synthetic indices at all because the name and type pair is the natural unique identifier for a Route53 recordset.

Does this get what you're looking for?
Dataset
RecordName,Type,Value
,A,1.2.3.4
,A,2.3.4.5
test,A,3.4.5.6
test,A,4.5.6.7
www,cname,example.com
main.tf
locals {
records = [for pref in {for _, key in distinct([for i, v in csvdecode(file("myDnsRecords.csv")): v.RecordName]): key => [for r in csvdecode(file("myDnsRecords.csv")): r if key == r.RecordName]}: {for i, r in pref: ("${r.RecordName}_${i}") => r}]
}
output "test" {
value = local.records
}
Output
Changes to Outputs:
+ test = [
+ {
+ _0 = {
+ RecordName = ""
+ Type = "A"
+ Value = "1.2.3.4"
}
+ _1 = {
+ RecordName = ""
+ Type = "A"
+ Value = "2.3.4.5"
}
},
+ {
+ test_0 = {
+ RecordName = "test"
+ Type = "A"
+ Value = "3.4.5.6"
}
+ test_1 = {
+ RecordName = "test"
+ Type = "A"
+ Value = "4.5.6.7"
}
},
+ {
+ www_0 = {
+ RecordName = "www"
+ Type = "cname"
+ Value = "example.com"
}
},
]
You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

Related

How can i set a count in for_each in terraform

I'm learning terraform by building a template to create my infrastructure in the hetzner cloud. For this purpose I'm using the hcloud provider.
I create a map variable hosts to create >1 server with different configuration.
variable "hosts" {
type = map(object({
name = string
serverType = string
serverImage = string
serverLocation = string
serverKeepDisk = bool
serverBackup = bool
ip = string
}))
}
This is working fine. But I need to configure also volumes. I need only for 2 servers additional volumes and terraform has to check if variable volume is true or not. If true a new volume with given details should be created and attached to the server.
For this I edit my variable hosts:
variable "hosts" {
type = map(object({
name = string
serverType = string
serverImage = string
serverLocation = string
serverKeepDisk = bool
serverBackup = bool
ip = string
volume = bool
volumeName = string
volumeSize = number
volumeFormat = string
volumeAutomount = bool
volumeDeleteProtection = bool
}))
}
in the main.tf the volume block looks like this, but it doesnt work because for_each and count cant be used together. How can I get what I'm looking for? Is that possible?
resource "hcloud_volume" "default" {
for_each = var.hosts
count = each.value.volume ? 1 : 0
name = each.value.volumeName
size = each.value.volumeSize
server_id = hcloud_server.default[each.key].id
automount = each.value.volumeAutomount
format = each.value.volumeFormat
delete_protection = each.value.volumeDeleteProtection
}
The former iterative meta-argument count will not provide you with the functionality you need here, as you need to access the volume bool type on a per var.hosts iteration in the map. To that end, you can add a conditional in a for expression within the for_each meta-argument.
for_each = { for host, values in var.hosts: host => values if values.volume }
This will construct a map for the value of the for_each meta-argument. It will contain every key value pair of var.hosts for which the volume object key is true.
It would seem like this would be a good fit for a collect or map method or function which transforms list and map types and exist in many other languages, but these do not yet exist in Terraform. Therefore, we use a for expression lambda equivalent.

How to concatenate strings in Terraform output with for loop?

I have multiple aws_glue_catalog_table resources and I want to create a single output that loops over all resources to show the S3 bucket location of each one. The purpose of this is to test if I am using the correct location (because it is a concatenation of variables) for each resource in Terratest. I cannot use aws_glue_catalog_table.* or aws_glue_catalog_table.[] because Terraform does not allow to reference a resource without specifying its name.
So I created a variable "table_names" with r1, r2, rx. Then, I can loop over the names. I want to create the string aws_glue_catalog_table.r1.storage_descriptor[0].location dynamically, so I can check if the location is correct.
resource "aws_glue_catalog_table" "r1" {
name = "r1"
database_name = var.db_name
storage_descriptor {
location = "s3://${var.bucket_name}/${var.environment}-config/r1"
}
...
}
resource "aws_glue_catalog_table" "rX" {
name = "rX"
database_name = var.db_name
storage_descriptor {
location = "s3://${var.bucket_name}/${var.environment}-config/rX"
}
}
variable "table_names" {
description = "The list of Athena table names"
type = list(string)
default = ["r1", "r2", "r3", "rx"]
}
output "athena_tables" {
description = "Athena tables"
value = [for n in var.table_names : n]
}
First attempt: I tried to create an output "athena_tables_location" with the syntax aws_glue_catalog_table.${table} but does does.
output "athena_tables_location" {
// HOW DO I ITERATE OVER ALL TABLES?
value = [for t in var.table_names : aws_glue_catalog_table.${t}.storage_descriptor[0].location"]
}
Second attempt: I tried to create a variable "table_name_locations" but IntelliJ already shows an error ${t} in the for loop [for t in var.table_names : "aws_glue_catalog_table.${t}.storage_descriptor[0].location"].
variable "table_name_locations" {
description = "The list of Athena table locations"
type = list(string)
// THIS ALSO DOES NOT WORK
default = [for t in var.table_names : "aws_glue_catalog_table.${t}.storage_descriptor[0].location"]
}
How can I list all table locations in the output and then test it with Terratest?
Once I can iterate over the tables and collect the S3 location I can do the following test using Terratest:
athenaTablesLocation := terraform.Output(t, terraformOpts, "athena_tables_location")
assert.Contains(t, athenaTablesLocation, "s3://rX/test-config/rX",)
It seems like you have an unusual mix of static and dynamic here: you've statically defined a fixed number of aws_glue_catalog_table resources but you want to use them dynamically based on the value of an input variable.
Terraform doesn't allow dynamic references to resources because its execution model requires building a dependency graph between all of the objects, and so it needs to know which exact resources are involved in a particular expression. However, you can in principle build your own single value that includes all of these objects and then dynamically choose from it:
locals {
tables = {
r1 = aws_glue_catalog_table.r1
r2 = aws_glue_catalog_table.r2
r3 = aws_glue_catalog_table.r3
# etc
}
}
output "table_locations" {
value = {
for t in var.table_names : t => local.tables[t].storage_descriptor[0].location
}
}
With this structure Terraform can see that output "table_locations" depends on local.tables and local.tables depends on all of the relevant resources, and so the evaluation order will be correct.
However, it also seems like your table definitions are systematic based on var.table_names and so could potentially benefit from being dynamic themselves. You could achieve that using the resource for_each feature to declare multiple instances of a single resource:
variable "table_names" {
description = "Athena table names to create"
type = set(string)
default = ["r1", "r2", "r3", "rx"]
}
resource "aws_glue_catalog_table" "all" {
for_each = var.table_names
name = each.key
database_name = var.db_name
storage_descriptor {
location = "s3://${var.bucket_name}/${var.environment}-config/${each.key}"
}
...
}
output "table_locations" {
value = {
for k, t in aws_glue_catalog_table.all : k => t.storage_descriptor[0].location
}
}
In this case aws_glue_catalog_table.all represents all of the tables together as a single resource with multiple instances, each one identified by the table name. for_each resources appear in expressions as maps, so this will declare resource instances with addresses like this:
aws_glue_catalog_table.all["r1"]
aws_glue_catalog_table.all["r2"]
aws_glue_catalog_table.all["r3"]
...
Because this is already a map, this time we don't need the extra step of constructing the map in a local value, and can instead just access this map directly to build the output value, which will be a map from table name to storage location:
{
r1 = "s3://BUCKETNAME/ENVNAME-config/r1"
r2 = "s3://BUCKETNAME/ENVNAME-config/r2"
r3 = "s3://BUCKETNAME/ENVNAME-config/r3"
# ...
}
In this example I've assumed that all of the tables are identical aside from their names, which I expect isn't true in practice but I was going only by what you included in the question. If the tables do need to have different settings then you can change var.table_names to instead be a variable "tables" whose type is a map of object type where the values describe the differences between the tables, but that's a different topic kinda beyond the scope of this question, so I won't get into the details of that here.

Terraform dynamically generate strings using data sources output

Is there any way I can feed the Terraform data source output to another Terraform file as input
The scenario is, I have a terraform code to fetch the private IP addresses (here 3 IPs 10.1.1.1,10.1.1.2,10.1.1.3) for particular tags(here jenkins) using data source
data "aws_instances" "mytag" {
filter {
name = "tag:Application"
values = ["jenkins"]
}
}
output "output from aws" {
value = data.aws_instances.mytag_private_ips
}
Whenever, I do the terraform apply, the on the pattern section in the
below metric-filter code should be able to fetch the resultant IPs from the above code and make them available in the live state ( aws console )
resource "aws_cloudwatch_log_metric_filter" "test" {
name = "test-metric-filter"
pattern = "[w1,w2,w3,w4!=\"*<IP1>*\"&&w4!=\"*<IP2>*\"&&w4!=\"*<IP3>*\",w5=\"*admin*\"]"
log_group_name = var.test_log_group_name
metric_transformation {
name ="test-metric-filter"
namespace = "General"
}
}
So, the final result of metric pattern in the aws console should be like below
[w1,w2,w3,w4!="*10.1.1.1*"&&w4!="*10.1.1.2*"&&w4!="*10.1.1.3*",w5="*admin*"]
The end goal is whenever if the new IPs are generated, it will get populated to pattern (in aws-console) without changing the metric-filter code.
Any help is appreciated, as I could not find any precise document on terraform allows us to dynamically generate strings using data sources
Not sure why you need two files for something this simple...
Here is what I would do:
provider "aws" {
region = "us-east-1"
}
data "aws_instances" "test" {
filter {
name = "architecture"
values = ["x86_64"]
}
}
resource "aws_cloudwatch_log_metric_filter" "test" {
name = "test-metric-filter"
pattern = "[w1,w2,w3,w4!=\"*${data.aws_instances.test.private_ips[0]}*\",w5=\"*admin*\"]"
log_group_name = "test_log_group_name"
metric_transformation {
name = "test-metric-filter"
namespace = "General"
value = 1
}
}
And a terraform plan will show
Terraform will perform the following actions:
# aws_cloudwatch_log_metric_filter.test will be created
+ resource "aws_cloudwatch_log_metric_filter" "test" {
+ id = (known after apply)
+ log_group_name = "test_log_group_name"
+ name = "test-metric-filter"
+ pattern = "[w1,w2,w3,w4!=\"*172.31.70.170*\",w5=\"*admin*\"]"
+ metric_transformation {
+ name = "test-metric-filter"
+ namespace = "General"
+ unit = "None"
+ value = "1"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Concatenating strings is easy: "foo ${var.bar} 123"
and on this case our private_ips is an array so we need the [x]
For more complex concatenations look into the format function:
https://www.terraform.io/docs/language/functions/format.html
I did changed the filter to be able to test on my environment and also used a shorter pattern than yours, but that is the basis for what you need, just add more of make changes to suit your needs.
What you are looking for is string interpolation in Terraform.
I believe you would want to do the following:
pattern = "[w1,w2,w3,w4!=\"*${data.aws_instances.mytag.private_ips[0]}*\"&&w4!=\"*${data.aws_instances.mytag.private_ips[1]}*\"&&w4!=\"*${data.aws_instances.mytag.private_ips[2]}*\",w5=\"*admin*\"]"
I suggest being careful with this statement, because it will fail if you don't have at least 3 instances. You would want to have something dynamic instead.

terraform count index and length

Trying to create multiple private DNS zone
non prod --> Dev,QA,UAT.
prod --> Prd,DR
Resource should be created only if is_nonprod is set to 1. emphasized text(boolean value).
Idea is to use count twice in resource block : once for boolean and once for length function.
resource "azurerm_private_dns_zone" "example" {
count = var.is_nonprod ? 1 : 0 && length(var.env)
name = var.env[count.index].npr
resource_group_name = "examplerg"
}
variable file:
variable "env" {
description = "List of routes to be added to the route table"
default = []
type = list(map(any))
}
variable "is_nonprod " {
default = true
}
tfvars
env = [
{ npr = "qa" },
{ npr = "uat" },
{ npr = "dev" }
]
Error :
The true and false result expressions must have consistent types. The given
expressions are number and bool, respectively.
workaround :
resource "azurerm_private_dns_zone" "example" {
count = var.is_nonprod ? 1 : 0
count = length(var.env)
name = var.env[count.index].npr
resource_group_name = "examplerg"
}
Error :
The argument "count" was already set at main.tf:96,3-8. Each argument may be
set only once.
var.is_nonprod ? 1 : 0 && length(var.env) does not look like the logic you want here based on what you described in the question. It seems like you really want var.is_nonprod ? length(var.env) : 0, which is also syntactically valid. The && operator inputs and returns booleans, which are not valid as an input type to the count metaparameter. count takes a number as input (typically the number of resources you want to manage), and not true or false.
Seems like datatype conflicts. You can directly use count = var.is_nonprod ? length(var.env) : 0 and it should give you expected results.

cloudflare terraform provider firewall creation with loop

I am trying to work around a constraint where firewall creation is split into 2 sections, creating a filter and creating the rule based on the filter. Filter creation exposes a filter id that should be used in the fw rule creation. I cant wrap my head on how to properly iterate through the map that has values for filter and rule and include newly created filter. if i just use a simple map with name and expression, things work, but if i add rule priority things break
here is my map
variable "fw_allowfilters1" {
description = "list of expressions for firewall to be included in the allow rules"
type = map(object({
fvalue = string
priority = number
}))
default = {
"office_filter1" = [
{
fvalue = "ip.geoip.asnum eq 111111"
priority = 1
}
]
"office_filter2" = [
{
fvalue = "ip.src eq 8.8.8.8"
priority = 3
}
]
}
}
now here is my code for both filter and FW
resource "cloudflare_filter" "allow-filters1" {
for_each = var.fw_allowfilters1
zone_id = var.zoneid
expression = each.value.fvalue
description = each.key
//description = [for o in var.fw_allowfilters1: "Filter_${var.fw_allowfilters1.name}"]
//expression = [for o in var.fw_allowfilters1: var.fw_allowfilters1.value]
}
resource "cloudflare_firewall_rule" "whitelist-rule" {
for_each = cloudflare_filter.allow-filters1
action = "allow"
filter_id = tostring(each.value.id)
zone_id = var.zoneid
description = [for p in var.fw_allowfilters1.name: p.name ]
priority = [for p in var.fw_allowfilters1.priority: p.priority ]
}
now if i dont include priority, i can do the for_each on the filter output in firewall creation, using id output from the resource and key for descirption ( cf tf provider uses description as a name) however, if i need to add the key, i need to iterate through the map with values plus the id that is output of the filter creation and I am not sure how to properly map it. code currently does not work.
so i figured it out and it was not easy:) using locals helped me create proper iterators:
resource "cloudflare_filter" "filters1" {
for_each = var.fw_rules
zone_id = var.zoneid
description = "Filter_${tostring(each.key)}"
expression = tostring(each.value[0])
}
locals {
filterids = [for f in cloudflare_filter.filters1 : f.id] //putting filter
IDs into a separate list for concat later
fwvalues = (values(var.fw_rules)) // putting values from the map of fwvalues into
a separate list to use the index position of a particular value as an interator when
creating commong object that has both filterid and fwvalues
fwkeys = (keys(var.fw_rules)) //putting keys into a separate list
//iterating over all elements in the allowfilters1, combining existing lists in the
variable with the ID value and readding the key as an element in the list
withid3 = {for p in var.fw_rules : local.fwkeys[index(local.fwvalues, p.*)] =>
concat(p, list(local.filterids[index(local.fwvalues,
p.*)]),list(local.fwkeys[index(local.fwvalues, p.*)]))} //working version
}
resource "cloudflare_firewall_rule" "fw-rules" {
for_each = local.withid3
action = each.value[2]
filter_id = each.value[4]
paused = each.value[3]
zone_id = var.zoneid
description = "FW-${tostring(each.value[2])}-${tostring(each.key)}"
priority = each.value[1]
}
where varilable is this:
// the syntax is the following: name of the rule(try to be precise = [ expression, priority,action, disabled - boolean] - all values should be strings, make sure to terminate the quotes correctly
allowed values for the action are: block, challenge, js_challenge, allow, log, bypass
list has to be maintained according to the rule priority
variable "fw_rules" {
description = "list of expressions for firewall to be included in therules"
type = map
default = {
office_allow = ["putexpressionhere","1","allow","false"],
office_allow1 = ["putexpressionhere1","2","deny","false"]
}

Resources