Terraform: цикли count, for_each та for

Автор |  04/09/2023
 

Продовжуємо розбиратись з можливостями Terraform.

В попредньому пості познайомились з типами даних – Terraform: знайомство з типами даних – primitives та complex. Тепер подивимось, як ці типи можна використовувати в циклах.

Terraform підтримує три типи циклів:

  • count: самий простий, використовується з заданим числом або з фукнцією length(); використовує індекси list або map для ітерації
    • підходить для створення однакових ресурсів, які не будуть змінюватись
  • for_each: має більше можливостей, використовується з map або set, використовує іммена ключів послідовності для ітерації
    • підходить для створення однотипних ресурсів, але з можливістю задати різні параметри
  • for: використовується для фільтрації та трансмормації об’єктів з lists, sets, tuples або maps; може бути використано разом з такими функціями, як if, join, replace, lower або upper

Terraform count

Отже, count самий базовий і перший метод для виконання задач в циклі.

Аргументом приймає або number, або list чи map, виконує ітерацію, і кожному об’єкту задає індекс відповідвідно до його позиції в послідовності.

Наприклад, ми можемо створити три корзини так:

resource "aws_s3_bucket" "bucket" {
  count = 3

  bucket = "bucket-${count.index}"
}

В результаті Terraform створить масив (array) з трох корзин з іменами bucket-0, bucket-1 та bucket-2.

Ми також можемо передати список і використати функцію length(), щоб отримати кількість елементів в цьому списку, і потім пройтись по кожному з них, використовуючи їхні індекси:

variable "projects" {
  type        = list(string)
  default     = ["test-project-1", "test-project-2", "test-project-3"]
}

resource "aws_s3_bucket" "bucket" {
  count = length(var.projects)

  bucket = "bucket-${var.projects[count.index]}"
}

В такому випадку будуть створені три корзини з іменами “bucket-test-project-1“, “bucket-test-project-2” та “bucket-test-project-3“.

Щоб отримати значеня імен корзин, які створювались таким чином, можемо використати “*” для вибору всіх індекісів з масиву aws_s3_bucket.bucket:

...
output "bucket_names" {
  value       = aws_s3_bucket.bucket[*].id 
}

Але у count є один важливий нюанс: саме через прив’язку елементів до індексів, ви може отримати несподіваний результат.

Наприклад, якщо створити ці три корзини, а потім додати новий проект на початку або всередені списку, то Terraform видалить коризини для проектів після доданого, бо в списку зміняться індекси об’єктів.

Тобто:

variable "projects" {
  type        = list(string)
  default     = ["test-project-1", "another-test-project", "test-project-2", "test-project-3"]
}

Приведе до:

$ terraform apply
...
  # aws_s3_bucket.bucket[1] must be replaced
-/+ resource "aws_s3_bucket" "bucket" {
...
      ~ bucket                      = "bucket-test-project-2" -> "bucket-another-test-project" # forces replacement
...
  # aws_s3_bucket.bucket[2] must be replaced
-/+ resource "aws_s3_bucket" "bucket" {
...
      ~ bucket                      = "bucket-test-project-3" -> "bucket-test-project-2" # forces replacement
...
  # aws_s3_bucket.bucket[3] will be created
  + resource "aws_s3_bucket" "bucket" {
...
      + bucket                      = "bucket-test-project-3"
...
Plan: 3 to add, 0 to change, 2 to destroy.

І якщо в корзинах є дані, то деплой зупиниться з помилкою BucketNotEmpty, бо Terraform буде намагатись видалити бакети.

Проте count чудово підійде, якщо вам треба перевірити умову на кшталт “створювати ресурс чи ні”. Це можна зробити таким чином:

variable "enabled" {
  type    = bool
  default = true
}

resource "aws_s3_bucket" "bucket" {
  count = var.enabled ? 1 : 0

  bucket = "bucket-test"
}

Тобто якщо enabled = true, то створюємо 1 корзину, якщо false – то 0.

Terraform for_each

for_each довзляє виконувати ітерації більш гнучко.

Він приймає map або set, і для ітерації замість індексів використовує кожен key та value з послідовності. В такому випадку саме кількість key буде визначати кількість ресурсів, котрі будуть створені.

Завдяки тому, що кожен key являється унікальним, зміна значень в set/map не впливає на те, як ресурси будуть створені.

Крім set та map ви можете використати тип list, але його треба буде “загорнути” у фунцію toset(), щоб перетворити на set, з якого for_each зможе отримати пару key:value – в такому випадку значення key буде == значенню value.

for_each з set та list

Отже, якщо взяти той же ресурс aws_s3_bucket, то з for_each ми можемо створити корзини так:

variable "projects" {
  type        = set(string)
  default     = ["test-project-1", "test-project-2", "test-project-3"]
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket = "bucket-${each.value}"
}

Або з variable з типом list і toset() для for_each:

variable "projects" {
  type        = list(string)
  default     = ["test-project-1", "test-project-2", "test-project-3"]
}

resource "aws_s3_bucket" "bucket" {
  for_each  = toset(var.projects)

  bucket = "bucket-${each.value}"
}

Але так як в результаті ми отримаємо не масив даних, а map з окремими об’єктами:

...
  # aws_s3_bucket.bucket["test-project-1"] will be created
...

І тоді в outputs просто викликати aws_s3_bucket.bucket[*].id ни вийде.

Натомість, ми можемо використати функцію values() щоб отримати всі значення ресурсів aws_s3_bucket.bucket:

...
output "bucket_names" {
  value       = values(aws_s3_bucket.bucket)[*].id 
}

for_each з map

Або приклад з map для створення тегу Name:

variable "projects" {
  type  = map(string)
  default = {
    "test-project-1" = "Test Project 1",
    "test-project-2" = "Test Project 2",
    "test-project-3" = "Test Project 3",
  }
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket   = "bucket-${each.key}"
  tags = {
    "Name" = each.value
  }
}

Або з використанням merge(), щоб додавати загальні теги + тег Name (див. також default_tags):

variable "projects" {
  type  = map(string)
  default = {
    "test-project-1" = "Test Project 1",
    "test-project-2" = "Test Project 2",
    "test-project-3" = "Test Project 3",
  }
}

variable "common_tags" {
  type    = map(string)
  default = {
    "Team"      = "devops",
    "CreatedBy" = "terraform"
  }
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket   = "bucket-${each.key}"
  tags  = merge(var.common_tags, {Name = each.value})
}

В результаті отримаємо три теги:

...
  ~ resource "aws_s3_bucket" "bucket" {
        id                          = "bucket-test-project-1"
      ~ tags                        = {
          + "CreatedBy" = "terraform"
          + "Name"      = "Test Project 1"
          + "Team"      = "devops"
        }
...

for_each з map of maps та атрибутами

Або можна використати навіть map of maps, і для кожної корзини передавати набір параметрів, і потім звертатись до параметра через each.value.PARAM_NAME.

Наприклад, в одному параметрі задамо тег Name, а в іншому – object_lock_enabled:

variable "projects" {
  type  = map(map(string))
  default = {
    "test-project-1" = {
      tag_name = "Test Project 1", object_lock_enabled = true 
    },
    "test-project-2" = {
      tag_name = "Test Project 2", object_lock_enabled = false
    },
    "test-project-3" = {
      tag_name = "Test Project 3", object_lock_enabled = false
    }
  }
}

variable "common_tags" {
  type    = map(string)
  default = {
    "Team"      = "devops",
    "CreatedBy" = "terraform"
  }
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket   = "bucket-${each.key}"
  object_lock_enabled = each.value.object_lock_enabled
  tags  = merge(var.common_tags, {Name = each.value.tag_name})
}

Результат:

Terraform for

На відміну від count та for_each, метод for використовується не для створення ресурсів, а для операцій фільтрування та трансформації над значеннями змінних.

Ситнаксис для for виглядає так:

[for <ITEM> in <LIST> : <OUTPUT>]

Тут ITEM – ім’я локальної до циклу змінної, LIST – список, в якому буде виконуватись ітерація, а OUTPUT – результат трансформації.

Наприклад, можемо вивести імена бакетів як UPPERCASE таким чином:

...
output "bucket_names" {
  value       = [for a in values(aws_s3_bucket.bucket)[*].id : upper(a)]
}

for та conditionals expressions

Також перед OUTPUT можемо додати фільтр, тобто виконати дію тільки над деякими об’єктами зі списку, наприклад:

output "bucket_names" {
  value       = [for a in values(aws_s3_bucket.bucket)[*].id : upper(a) if can(regex(".*-1", a))]
}

Тут ми за допомогою функцій can() та regex() перевіряємо значення змінної a, і якщо вона закінчується на “-1”, то виконуємо upper(a):

...
bucket_names = [
  "BUCKET-TEST-PROJECT-1",
]

for та ітерація по map

Можно виконати ітерацію над key:value з map variable:

variable "common_tags" {
  type    = map(string)
  default = {
    "Team"      = "devops",
    "CreatedBy" = "terraform"
  }
}

output "common_tags" {
  value       = [for a, b in var.common_tags : "Key: ${a} value: ${b}" ]
}

В результаті отримаємо об’єкт типу list зі значеннями:

...
common_tags = [
  "Key: CreatedBy; Value: terraform;",
  "Key: Team; Value: devops;",
]

А за допомогою  => можемо перетворити list на map. Крім того, для map замість [] цикл записуємо в {}:

output "common_tags" {
  value       = { for a, b in var.common_tags : upper(a) => b }
}

Отримуємо:

...
common_tags = {
  "CREATEDBY" = "terraform"
  "TEAM" = "devops"
}

for та for_each для ітерації над complex objects

Можна зробити єдину змінну, яка буде мати різні типи даних для різних значень, а потім виконати ітерацію з for_each та for разом.

Наприклад, створимо variable з типом list, в якому будуть значення типу object, а в object будуть два поля типу string, та одне для списку тегів з типом list:

variable "projects" {
  type        = list(object({
      name = string
      object_lock_enabled = string
      tags = map(string)
  }))

  default = [
    {
      name  = "test-project-1"
      object_lock_enabled = "true"
      tags  =         {
          "Name" = "Test Project 1"
          "Team"      = "devops"
          "CreatedBy" = "terraform"          
        }
    },
    {
      name  = "test-project-2",
      object_lock_enabled = true,
      tags  =         {
          "Name" = "Test Project 2",
          "Team"      = "devops",
          "CreatedBy" = "terraform"          
        }
    },
    {
      name  = "test-project-3",
      object_lock_enabled = true,
      tags  =         {
          "Name" = "Test Project 3",
          "Team"      = "devops",
          "CreatedBy" = "terraform"          
        }
      
    }        
  ]
}

resource "aws_s3_bucket" "bucket" {
  for_each  = { for a in var.projects : a.name => a }

  bucket   = "bucket-${each.key}"
  object_lock_enabled = each.value.object_lock_enabled
  tags = { for key,value in each.value.tags : key => value }
}

Потім в ресурсі aws_s3_bucket в цикл for_each передаємо значення var.projects.name, а для тегів робимо цикл по кожному ресурсу з list, і в кожному ресурсі створюємо key:value з each.value.tags.

Nested for loops для map of lists

Для роботи з багаторівневими об’єктами в одному циклі for можна визивати інший.

Наприклад, маємо список проектів, для кожного є один чи кілька “dev/prod” оточень:

variable "projects" {
  description = "project names list to be used in S3 and DynamoDB names"
  type        = map(list(string))

  default = {
    atlas-tf-backends-test = [
      "prod"
    ]
    atlas-eks-test = [
      "dev", "prod"
    ]
  }
}

Щоб побудувати list з елементами, які будуть містити ім’я проекту + ім’я оточення – використовуємо два for:

locals {
  table_names = [
    for project, envs in var.projects : [
      for env in envs : 
        "${project}-${env}"
    ]
  ]
}

output "dynamodb_table_names" {
  value = local.table_names
}

В результаті отримаємо:

Changes to Outputs:
  + dynamodb_table_names = [
      + [
          + "atlas-eks-test-dev",
          + "atlas-eks-test-prod",
        ],
      + [
          + "atlas-tf-backends-test-prod",
        ],
    ]

А щоб створити єдиний list замість list[list, list] – можна використати фунцію flatten:

locals {
  table_names = flatten([
    for project, envs in var.projects : [
      for env in envs : 
        "${project}-${env}"
    ]
  ])
}

В результаті отримаємо:

Changes to Outputs:
  + dynamodb_table_names = [
      + "atlas-eks-test-dev",
      + "atlas-eks-test-prod",
      + "atlas-tf-backends-test-prod",
    ]

А щоб побудувати map, де ключами будуть ім’я проекту + ім’я, а в значенні інший map – можно використати функцію merge та оператор “...“, як наведено в цьому коментарі на GitHub:

locals {
  table_names_map = merge([
    for project, envs in var.projects : {
      for env in envs :
      "${project}-${env}" => {
        "project" = project
        "env"     = env
      }
    }
  ]...)
}

output "dynamodb_table_names" {
  value = local.table_names_map
}

Результат:

Changes to Outputs:
  + dynamodb_table_names = {
      + atlas-eks-test-dev          = {
          + env     = "dev"
          + project = "atlas-eks-test"
        }
      + atlas-eks-test-prod         = {
          + env     = "prod"
          + project = "atlas-eks-test"
        }
      + atlas-tf-backends-test-prod = {
          + env     = "prod"
          + project = "atlas-tf-backends-test"
        }
    }

 

for та String Templates

Документація – Strings and Templates.

Синтаксис для ітерації по map буде таким:

%{ for <KEY>, <VALE> in <COLLECTION> }<RESULTED_TEXT>%{ endfor }

Тобто, можемо створити текствий файл зі змістом значень змінної:

resource "local_file" "foo" {
  content  = "%{ for a, b in var.common_tags }Key: ${a}\nValue: ${b}\n%{ endfor }"
  filename = "foo.txt"
}

Результат:

$ cat foo.txt 
Key: CreatedBy
Value: terraform
Key: Team
Value: devops

Готово.

Посилання по темі