I'd love some help with Terraform's count/for_each functions.
The goal is to read multiple json files(current two) into a list of maps and create specific amount of aws_instances with specific naming convention.
Configurations
cat test_service_1.json
{
"instance_name": "front",
"instance_count": "3",
"instance_type": "t2.micro",
"subnet_type": "private",
"elb": "yes",
"data_volume": ["no", "0"]
}
cat test_service_2.json
{
"instance_name": "back",
"instance_count": "3",
"instance_type": "t2.micro",
"subnet_type": "private",
"elb": "yes",
"data_volume": ["no", "0"]
}
cat main.tf
locals {
services = [jsondecode(file("${path.module}/test_service_1.json")),
jsondecode(file("${path.module}/test_service_2.json"))]
}
resource "aws_instance" "test_instance" {
ami = "amzn-ami-hvm-2018.03.0.20200206.0-x86_64-gp2"
instance_type = "t2.micro"
tags = merge(
map("Name", "prod-app-?"),
map("env", "prod")
)
}
Eventually I want the code to go over both json files and create:
prod-front-1
prod-front-2
prod-front-3
prod-back-1
prod-back-2
prod-back-3
I can do that with [count.index +1] but I don't know how to loop through more than one map.
When using resource for_each our task is always to write an expression that produces a map where there is one element per instance we want to create. In this case, that seems to be an expression that can expand a single object containing a count to instead be multiple objects of the number given in the count.
The building blocks we can use to do this in Terraform are:
for expressions to project one collection value into another.
The range function to generate sequences of integers given a count.
The flatten function to turn multiple nested lists into a single flat list.
Let's take this step by step. We'll start with your existing expression to load the data from the JSON files:
locals {
services = [
jsondecode(file("${path.module}/test_service_1.json")),
jsondecode(file("${path.module}/test_service_2.json")),
]
}
The result of this expression is a list of objects, one per file. Next, we'll expand each of those objects into a list of objects whose length is given in instance_count:
locals {
service_instance_groups = [
for svc in local.services : [
for i in range(1, svc.instance_count+1) : {
instance_name = "${svc.instance_name}-${i}"
instance_type = svc.instance_type
subnet_type = svc.subnet_type
elb = svc.elb
data_volume = svc.data_volume
}
]
]
}
The result of this one is a list of lists of objects, each of which will have a unique instance_name value due to concatenating the value i to the end.
To use for_each though we we will need a flat collection with one element per instance, so we'll use the flatten function to achieve that:
locals {
service_instances = flatten(local.service_instance_groups)
}
Now we have a list of objects again, but with six elements (three from each of the two input objects) instead of two.
Finally, we need to project that list to be a map whose keys are the unique identifiers Terraform will use to track the instances. I usually prefer to do this final step directly inside the for_each argument because this result is specific to that use-case and unlikely to be used anywhere else in the module:
resource "aws_instance" "test_instance" {
for_each = {
for inst in local.service_instances : inst.instance_name => inst
}
ami = "amzn-ami-hvm-2018.03.0.20200206.0-x86_64-gp2"
instance_type = each.value.instance_type
tags = {
Name = "prod-app-${each.key}"
Env = "prod"
}
}
This should result in Terraform planning to create instances with addresses like aws_instance.test_instance["front-2"].
I wrote each of the above steps out separately to explain what each one was achieving, but in practice I'd usually do the service_instance_groups and service_instances steps together in a single expression, because that intermediate service_instance_groups result isn't likely to be reused elsewhere. Bringing that all together into a single example, then:
locals {
services = [
jsondecode(file("${path.module}/test_service_1.json")),
jsondecode(file("${path.module}/test_service_2.json")),
]
service_instances = flatten([
for svc in local.services : [
for i in range(1, svc.instance_count+1) : {
instance_name = "${svc.instance_name}-${i}"
instance_type = svc.instance_type
subnet_type = svc.subnet_type
elb = svc.elb
data_volume = svc.data_volume
}
]
])
}
resource "aws_instance" "test_instance" {
for_each = {
for inst in local.service_instances : inst.instance_name => inst
}
ami = "amzn-ami-hvm-2018.03.0.20200206.0-x86_64-gp2"
instance_type = each.value.instance_type
tags = {
Name = "prod-app-${each.key}"
Env = "prod"
}
}
As a bonus, beyond what you were asking about here, if you give those JSON files systematic names and group them together into a subdirectory then you could use Terraform's fileset function to to automatically pick up any new files added in that directory later, without changing the Terraform configuration. For example:
locals {
services = [
for fn in fileset("${path.module}", "services/*.json") :
jsondecode(file("${path.module}/${fn}"))
]
}
The above will produce a list containing an object for each of the files in the services subdirectory that have names ending in .json.
Related
On terraform v0.14.4
My variable looks like this:
variable "my_config" {
type = object({
instances = set(string)
locations = set(string)
})
default = {
locations = [
"us",
"asia"
]
instances = [
"instance1",
"instance2"
]
}
I want to loop over this var in a resource and create an instance of the resource for each location + instance. The "name" field of the resource will be "<LOCATION>_<INSTANCE>" as well.
I could create a new var in locals that reads the my_config var and generates a new var that looks like this:
[
"us_instance1",
"us_instance2",
"asia_instance1",
"asia_instance2",
]
I would prefer to not generate a new terraform var from this existing var though. Is it possible in a foreach loop to aggregate these two lists directly in a resource definition? Or is the only way to create a new data structure in locals?
EDIT
I cannot get the flatten example in answer provided to work inside a resource definition. I get this error: The given "for_each" argument value is unsuitable: the "for_each" argument must be a map, or set of strings, and you have provided a value of type tuple. This error happens if the type is set(string) or list(string).
# This works
output "test" {
value = flatten(
[
for location in var.my_config.locations : [
for instance in var.my_config.instances : "${location}_${instance}"
]
]
)
}
# This throws the error
resource "null_resource" "test" {
for_each = flatten(
[
for location in var.my_config.locations : [
for instance in var.my_config.instances : "${location}_${instance}"
]
]
)
provisioner "local-exec" {
command = "echo test"
}
}
To achieve the return value of:
[
"us_instance1",
"us_instance2",
"asia_instance1",
"asia_instance2",
]
with the input of the variable my_config, you could:
flatten([for location in var.my_config.locations : [
for instance in var.my_config.instances : "${location}_${instance}"
]])
Whether or not you define this in a locals block is up to you. If you plan on re-using this value multiple times, then it would be more efficient to define it as a local. If you plan on on only using it once, then it would certainly make more sense to not define it in locals.
Note this also assumes my_config type is object(list(string)). The type was not given in the question, but if the type were otherwise then the code becomes much more obfuscated.
For the additional question about using this value as a for_each meta-argument value at the resource scope, it would need to be converted to type set(string). This can be done easily with the toset function:
resource "resource" "this" {
for_each = toset(<expression above or variable with return value of above assigned to it>)
}
I am very new to terraform and had a task dropped upon me to create 2 AWS KMS keys.
So I am doing this:
resource "aws_kms_key" "ebs_encryption_key" {
description = "EBS encryption key"
... omitted for brevity ...
tags = merge(map(
"Name", format("%s-ebs-encryption-key", var.name_prefix),
"component", "kms",
"dataclassification","low",
), var.extra_tags)
}
resource "aws_kms_alias" "ebs_encryption_key" {
name = format("alias/%s-ebs-encryption-key", var.name_prefix)
target_key_id = aws_kms_key.ebs_encryption_key.key_id
}
# Repeated code!
resource "aws_kms_key" "rds_encryption_key" {
description = "RDS encryption key"
... omitted for brevity ...
tags = merge(map(
"Name", format("%s-rds-encryption-key", var.name_prefix),
"component", "kms",
"dataclassification","low",
), var.extra_tags)
}
resource "aws_kms_alias" "rds_encryption_key" {
name = format("alias/%s-rds-encryption-key", var.name_prefix)
target_key_id = "${aws_kms_key.rds_encryption_key.key_id}"
}
As you can see the only difference between the two blocks of code is "ebs" and "rds"?
How could I use a for loop to avoid repeating the code blocks?
This seems like it could be a candidate for a small module that encapsulates the details of declaring a key and an associated alias, since a key and an alias are typically declared together in your system.
The module itself would look something like this:
variable "name" {
type = string
}
variable "description" {
type = string
}
variable "tags" {
type = map(string)
}
resource "aws_kms_key" "main" {
description = var.description
# ...
tags = var.tags
}
resource "aws_kms_alias" "main" {
name = "alias/${var.name}"
target_key_id = aws_kms_key.main.key_id
}
output "key_id" {
value = aws_kms_key.main.key_id
}
output "alias_name" {
value = aws_kms_alias.main.name
}
(As written here this module feels a little silly because there's not really much here that isn't derived only from the variables, but I'm assuming that the interesting stuff you want to avoid repeating is in "omitted for brevity" in your example, which would go in place of # ... in my example.)
Your calling module can then include a module block that uses for_each to create two instances of the module, systematically setting the arguments to populate its input variables:
module "kms_key" {
for_each = {
kms = "KMS"
ebs = "EBS"
}
name = "${var.name_prefix}-${each.key}-encryption-key"
description = "${each.value} Encryption Key"
tags = merge(
var.extra_tags,
{
Name = "${var.name_prefix}-${each.key}-encryption-key"
component = "kms"
dataclassification = "low"
},
)
}
Since the for_each map here has the keys kms and ebs, the result of this will be to declare resource instances which should have the following addresses in the plan:
module.kms_key["kms"].aws_kms_key.main
module.kms_key["kms"].aws_kms_alias.main
module.kms_key["ebs"].aws_kms_key.main
module.kms_key["ebs"].aws_kms_alias.main
Since they are identified by the map keys, you can add new keys to that map in future to create new key/alias pairs without disturbing the existing ones.
If you need to use the key IDs or alias names elsewhere in your calling module then you can access them via the outputs exposed in module.kms_key elsewhere in that calling module:
module.kms_key["kms"].key_id
module.kms_key["kms"].alias_name
module.kms_key["ebs"].key_id
module.kms_key["ebs"].alias_name
I’m trying to write some code which would take an input structure like this:
projects = {
"project1" = {
namespaces = ["mynamespace1"]
},
"project2" = {
namespaces = ["mynamespace2", "mynamespace3"]
}
}
and provision multiple resources with for_each which would result in this:
resource "rancher2_project" "project1" {
provider = rancher2.admin
cluster_id = module.k8s_cluster.cluster_id
wait_for_cluster = true
}
resource "rancher2_project" "project2" {
provider = rancher2.admin
cluster_id = module.k8s_cluster.cluster_id
wait_for_cluster = true
}
resource "rancher2_namespace" "mynamespace1" {
provider = rancher2.admin
project_id = rancher2_project.project1.id
depends_on = [rancher2_project.project1]
}
resource "rancher2_namespace" "mynamespace2" {
provider = rancher2.admin
project_id = rancher2_project.project2.id
depends_on = [rancher2_project.project2]
}
resource "rancher2_namespace" "mynamespace3" {
provider = rancher2.admin
project_id = rancher2_project.project2.id
depends_on = [rancher2_project.project2]
}
namespaces are dependent on Projects and the generate id needs to be passed into namespace.
Is there any good way of doing this dynamically ? We might have a lot of Projects/namespaces.
Thanks for any help and advise.
The typical answer for systematically generating multiple instances of a resource based on a data structure is resource for_each. The main requirement for resource for_each is to have a map which contains one element per resource instance you want to create.
In your case it seems like you need one rancher2_project per project and then one rancher2_namespace for each pair of project and namespaces. Your current data structure is therefore already sufficient for the rancher2_project resource:
resource "rancher2_project" "example" {
for_each = var.projects
provider = rancher2.admin
cluster_id = module.k8s_cluster.cluster_id
wait_for_cluster = true
}
The above will declare two resource instances with the following addresses:
rancher2_project.example["project1"]
rancher2_project.example["project2"]
You don't currently have a map that has one element per namespace, so it will take some more work to derive a suitable value from your input data structure. A common pattern for this situation is flattening nested structures for for_each using the flatten function:
locals {
project_namespaces = flatten([
for pk, proj in var.projects : [
for nsk in proj.namespaces : {
project_key = pk
namespace_key = ns
project_id = rancher2_project.example[pk].id
}
]
])
}
resource "rancher2_namespace" "example" {
for_each = {
for obj in local.project_namespaces :
"${obj.project_key}.${obj.namespace_key}" => obj
}
provider = rancher2.admin
project_id = each.value.project_id
}
This produces a list of objects representing all of the project and namespace pairs, and then the for_each argument transforms it into a map using compound keys that include both the project and namespace keys to ensure that they will all be unique. The resulting instances will therefore have the following addresses:
rancher2_namespace.example["project1.mynamespace1"]
rancher2_namespace.example["project2.mynamespace2"]
rancher2_namespace.example["project2.mynamespace3"]
This seems to work too:
resource "rancher2_namespace" "example" {
count = length(local.project_namespaces)
provider = rancher2.admin
name = local.project_namespaces[count.index].namespace_name
project_id = local.project_namespaces[count.index].project_id
}
Let's say I have a map of environments to supply to for_each
environments = {
"0" = "dev"
"1" = "test"
"2" = "stage"
}
Then for whatever reason I want to create an Azure Resource Group for each environment.
resource "azurerm_resource_group" "resource_group" {
for_each = var.environments
name = "${var.resource-group-name}-${each.value}-rg"
location = var.location
}
How do I get the outputs? I've tried the new splat to no avail.
output "name" {
value = "${azurerm_resource_group.resource_group[*].name}"
}
output "id" {
value = "${azurerm_resource_group.resource_group[*].id}"
}
output "location" {
value = "${azurerm_resource_group.resource_group[*].location}"
}
Error: Unsupported attribute
in output "id":
6: value = "${azurerm_resource_group.resource_group[*].id}"
This object does not have an attribute named "id".
How do I output an attribute of multiple instances of a resource created with for_each?
The [*] is a shorthand for extracting attributes from a list of objects. for_each makes a resource appear as a map of objects instead, so the [*] operator is not appropriate.
However, for expressions are a more general mechanism that can turn either a list or a map into another list or map by evaluating an arbitrary expression for each element of the source collection.
Therefore we can simplify a map of azurerm_resource_group objects into a map of names of those objects like this:
output "name" {
value = { for k, group in azurerm_resource_group.resource_group: k => group.name }
}
Your input map uses numeric indexes as keys, which is unusual but allowed. Because of that, the resulting value for the output would be something like this:
{
"0" = "something-dev-rg"
"1" = "something-test-rg"
"2" = "something-stage-rg"
}
It's more common for a map in for_each to include a meaningful name as the key, so that the resulting instances are identified by that meaningful name rather than by incrementing integers. If you changed your configuration to use the environment name as the key instead, the map of names would look like this instead:
{
"dev" = "something-dev-rg"
"test" = "something-test-rg"
"stage" = "something-stage-rg"
}
EDIT: for_each doesn't work with output
output "name"{
value = { for k, v in var.environments : v => azurerm_resource_group.resource_group[k].name }
}
output "id"{
value = { for k, v in var.environments : v => azurerm_resource_group.resource_group[k].id }
}
output "location"{
value = { for k, v in var.environments : v => azurerm_resource_group.resource_group[k].location }
}
Example output,
id = {
"dev" = "xxx"
"stage" = "yyy"
"test" = "zzz"
}
I am using Terraform v12.19 with the aws provider v2.34.0.
Imagine, I have a resource generated with a count value:
resource "aws_iam_role" "role" {
count = length(var.somevariable)
name = var.somevariable[count.index]
}
Later on, I want to reference one specific resource instance in that way, e. g.:
resource "aws_iam_role_policy_attachment" "polatt" {
role = aws_iam_role.role["TheRoleNameIWant"].id
policy_arn = "arn:aws:iam::aws:policy/..."
}
I don't know the index, I can just rely on the name, provided by the variable. Thats because the values of the variable are provided by an external source and the order could change...
Any ideas how to do this?
You should be able to accomplish this using the index terraform function.
Here's a minimal example using null_resources to test it out
locals {
role_names = [
"role-a",
"role-b",
"role-c",
"role-d",
]
target_role_name = "role-c"
}
resource "null_resource" "hi" {
count = length(local.role_names)
}
output "target_resource" {
value = null_resource.hi[index(local.role_names, local.target_role_name)].id
}
output "all_resources" {
value = [for r in null_resource.hi : r.id]
}
This outputs, for example
all_resources = [
"4350570701002192774",
"9173388682753384584",
"1634695740603384613",
"2098863759573339880",
]
target_resource = 1634695740603384613
So your example, I suppose, would look like
resource "aws_iam_role_policy_attachment" "polatt" {
role = aws_iam_role.role[index(var.somevariable, "TheRoleNameIWant")].id
policy_arn = "arn:aws:iam::aws:policy/..."
}
Update
Your comment below mentions that you actually have a more complicated data structure than just a list of names. I just wanted to mention that you can derive names from your JSON structure.
Assuming you have something like the following
variable "role_values" {
value = [
{
name = "foo",
other = "details",
fields = 3
},
{
name = "bar",
other = "yet more details",
fields = 3
}
]
}
you could derive just the names by using a local and the newer for loops TF 0.12 offers
locals {
role_names = [for role in var.role_values: role.name]
}
That way you don't have to store the names twice.