У нас є автоматизація для AWS IAM, яка створює EKS Access Entries для підключення AWS IAM Users до кластеру.
Не пам’ятаю, чи я писав її сам, чи нагенерила якась LLM (хоча судячи з коду – писав сам 🙂 ), але згодом виявилась неприємна особливість того, як ця автоматизація працює: при видаленні юзера Terraform починає робити “re-mapping” інших юзерів.
Власне, сьогодні глянемо на те, як я це все діло робив, згадаємо типи даних Terraform, і подивимось як треба було зробити, аби такої проблеми не виникало.
Хоча помилка описана щодо aws_eks_access_entry
(тут в прикладах буде local_file
замість aws_eks_access_entry
), насправді вона стосується загального підходу до використання індексів та циклів у Terraform.
Зміст
Поточна реалізація
Спрощено вона виглядає так:
variable "eks_clusters" { description = "List of EKS clusters to create records" type = set(string) default = [ "cluster-1", "cluster-2" ] } variable "eks_users" { description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User" type = map(list(string)) default = { backend = [ "user1", "user2", "user3", ] } } locals { eks_users_access_entries_backend = flatten([ for cluster in var.eks_clusters : [ for user_arn in var.eks_users.backend : { cluster_name = cluster principal_arn = user_arn } ] ]) } resource "local_file" "backend" { for_each = { for cluster, user in local.eks_users_access_entries_backend : cluster => user } filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt" content = <<EOF cluster_name=${each.value.cluster_name} principal_arn=${each.value.principal_arn} EOF }
Тільки в оригіналі замість resource "local_file"
використовується resource "aws_eks_access_entry"
.
Власне, в цьому коді:
variable "eks_clusters"
: містить список наших EKS кластерів, до яких треба підключити юзерівvariable "eks_users"
: містить списки груп (бекенд в цьому прикладі) і юзерів в цій групі – user1, user2, user3locals.eks_users_access_entries_backend
: створює список на кожну унікальну комбінацію EKS кластер + IAM юзерresource "local_file"
: для кожного кластера і кожного юзера створює файл з іменем у вигляді [email protected]
Тепер поглянемо детальніше на змінні та типи даних – бо робив це давно, корисно самому згадати.
Variables та типи даних
variable "eks_clusters"
Тип просто set(string)
з двома елементами – “cluster-1” та “cluster-2“:
variable "eks_clusters" { description = "List of EKS clusters to create records" type = set(string) default = [ "cluster-1", "cluster-2" ] }
Тип set[]
не має індексів, і звернення до об’єктів виконується в будь-якому порядку.
variable "eks_users"
variable "eks_users" { description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User" type = map(list(string)) default = { backend = [ "user1", "user2", "user3", ] } }
Тут у нас вже змінна з типом map(list(string))
.
map{}
– це набір key => value даних, де key
– ім’я групи юзерів (devops, backend, qa), а в value
маємо вкладений list[]
з об’єктами типу string
, де кожен об’єкт – це ім’я юзера.
А list
– на відміну від set
– окрім інших відмінностей має індекси для кожного елемента, а тому порядок звернення до об’єктів у list
буде по черзі.
Тобто ми можемо звернутись до них за індексами 0-1-2:
eks_users["backend"].0
: буде user1eks_users["backend"].1
: буде user2eks_users["backend"].2
: буде user3
Можемо це вивести це в outputs
:
output "eks_users_all" { value = var.eks_users } output "eks_users_backend" { value = var.eks_users["backend"] } output "eks_users_backend_user_1" { value = var.eks_users["backend"].0 } output "eks_users_backend_user_2" { value = var.eks_users["backend"].1 } ...
І в результаті terraform apply
отримаємо:
$ terraform apply ... eks_users_all = tomap({ "backend" = tolist([ "user1", "user2", ]) }) eks_users_backend = tolist([ "user1", "user2", ]) eks_users_backend_user_1 = "user1" eks_users_backend_user_2 = "user2"
Або просто глянути в terraform console
:
> var.eks_users["backend"].0 "user1" > var.eks_users["backend"].1 "user2" > var.eks_users["backend"].2 "user3"
Але проблема виникає не через самі індекси – а через те, як вони змінюються, якщо елемент зі списку видаляється або переміщується, особливо якщо ці індекси використовуються як ключі для for_each
. Власне, скоро до цього дійдемо.
local.eks_users_access_entries_backend
locals { eks_users_access_entries_backend = flatten([ for cluster in var.eks_clusters : [ for user_arn in var.eks_users.backend : { cluster_name = cluster principal_arn = user_arn } ] ]) }
Тут ми використовуємо подвійний for
, який перебирає кожен кластер із set(string)
в var.eks_clusters
, а потім кожного юзера із list(string)
в var.eks_users.backend
.
Давайте поки приберемо flatten()
:
... eks_users_access_entries_backend_unflatten = [ for cluster in var.eks_clusters : [ for user_arn in var.eks_users.backend : { cluster_name = cluster principal_arn = user_arn } ] ] ...
Тепер в eks_users_access_entries_backend_unflatten = [ ... ]
ми отримуємо вкладений список – list(list(object))
:
- зовнішній
name = [ ... ]
– це перший рівень списку, де кожен елемент – це кластер ізvar.eks_clusters
- далі з
for cluster in var.eks_clusters : [ ... ]
ми формуємо окремий вкладений список об’єктів для кожного кластеру - і потім з
for user_arn in var.eks_users.backend : { ... }
створюються objects з полямиcluster_name
таprincipal_arn
– по одному об’єкту на кожну паруcluster_name
таprincipal_arn
Теж глянемо з terraform console
:
> local.eks_users_access_entries_backend_unflatten tolist([ [ { "cluster_name" = "cluster-1" "principal_arn" = "user1" }, { "cluster_name" = "cluster-1" "principal_arn" = "user2" }, { "cluster_name" = "cluster-1" "principal_arn" = "user3" }, ], [ { "cluster_name" = "cluster-2" "principal_arn" = "user1" }, { "cluster_name" = "cluster-2" "principal_arn" = "user2" }, { "cluster_name" = "cluster-2" "principal_arn" = "user3" }, ], ])
А flatten()
нам просто прибирає цю вкладеність list(list(object))
, і перетворює результат в плаский list(object)
, де кожен об’єкт – це унікальна пара кластер + юзер:
... eks_users_access_entries_backend = [ { "cluster_name" = "cluster-1" "principal_arn" = "user1" }, { "cluster_name" = "cluster-1" "principal_arn" = "user2" }, { "cluster_name" = "cluster-2" "principal_arn" = "user1" }, { "cluster_name" = "cluster-2" "principal_arn" = "user2" }, ] ...
Окей – з цим розібрались, йдемо далі – подивимось, як цей список буде використовувати в for_each
, і які помилки я там допустив.
resource "local_file" "backend"
Ну і основне – використовуючи це список ми створюємо юзерів для кожного кластеру:
... resource "local_file" "backend" { for_each = { for cluster, user in local.eks_users_access_entries_backend : cluster => user } filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt" content = <<EOF cluster_name=${each.value.cluster_name} principal_arn=${each.value.principal_arn} EOF }
В оригіналі це виглядає так:
resource "aws_eks_access_entry" "backend" { for_each = { for cluser, user in local.eks_users_access_entries_backend : cluser => user } cluster_name = each.value.cluster_name principal_arn = each.value.principal_arn kubernetes_groups = [ "backend-team" ] }
І знову цикли і списки 🙂
Що ми тут маємо: з { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
формується map{}
, де ключем (cluster) стає індекс кожного елементу зі списку local.eks_users_access_entries_backend, а значенням (user) – сам object з цього списку за цим індексом, і цей об’єкт містить поля cluster_name
та principal_arn
.
Тобто, в cluster
ми будемо мати значення 0, 1, 2, а в user
– значення { cluster_name = "cluster-1", principal_arn = "user1" }
, { cluster_name = "cluster-1", principal_arn = "user2" }
, { cluster_name = "cluster-1", principal_arn = "user3" }
відповідно.
Отже, перша моя помилка – це взагалі імена cluster та user в самому циклі for
, бо правильніше було б їх назвати просто for index, entry in ...,
ну або ж index (чи idx) та user – бо все ж в кожному user маємо комбінацію, яка ідентифікую саме юзера – кластер:юзер.
Ще один нюанс: оскільки в цьому for_each
ключем виступає індекс з типом number, а значення – це об’єкт з типом object, то ми отримуємо не map{}
– а object{}
, бо в map
ключ та значення мають бути одного типу, і Terraform не може створити map(number => object)
:
> type({ for cluster, user in local.eks_users_access_entries_backend : cluster => user }) object({ 0: object({ cluster_name: string, principal_arn: string, }), 1: object({ cluster_name: string, principal_arn: string, }), ...
Хоча зараз це не принципово.
The Issue
Тепер йдемо далі, до головної проблеми: якщо ми в списку юзерів variable "eks_users"
видаляємо юзера, тобто замість:
variable "eks_users" { description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User" type = map(list(string)) default = { backend = [ "user1", "user2", "user3", ] } }
Зробимо:
variable "eks_users" { description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User" type = map(list(string)) default = { backend = [ "user1", "user3", ] } }
То це призведе до того, що, по-перше, зміниться local.eks_users_access_entries_backend
, бо замість 6 об’єктів:
> local.eks_users_access_entries_backend [ { "cluster_name" = "cluster-1" "principal_arn" = "user1" }, { "cluster_name" = "cluster-1" "principal_arn" = "user2" }, { "cluster_name" = "cluster-1" "principal_arn" = "user3" }, { "cluster_name" = "cluster-2" "principal_arn" = "user1" }, { "cluster_name" = "cluster-2" "principal_arn" = "user2" }, { "cluster_name" = "cluster-2" "principal_arn" = "user3" }, ]
Ми отримаємо новий список з 4 об’єктами:
> local.eks_users_access_entries_backend [ { "cluster_name" = "cluster-1" "principal_arn" = "user1" }, { "cluster_name" = "cluster-1" "principal_arn" = "user3" }, { "cluster_name" = "cluster-2" "principal_arn" = "user1" }, { "cluster_name" = "cluster-2" "principal_arn" = "user3" }, ]
А оскільки for_each
формується на основі індексів списку local.eks_users_access_entries_backend
:
for_each = { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
То коли зміниться кількість елементів в local.eks_users_access_entries_backend
– то map{}
(котрий все ж object) в умові для for_each
зміниться теж, бо він створюється на основі індексів списку local.eks_users_access_entries_backend
.
Тобто замість 0, 1, … 5:
> { for cluster, user in local.eks_users_access_entries_backend : cluster => user } { "0" = { "cluster_name" = "cluster-1" "principal_arn" = "user1" } "1" = { "cluster_name" = "cluster-1" "principal_arn" = "user2" } "2" = { "cluster_name" = "cluster-1" "principal_arn" = "user3" } "3" = { "cluster_name" = "cluster-2" "principal_arn" = "user1" } "4" = { "cluster_name" = "cluster-2" "principal_arn" = "user2" } "5" = { "cluster_name" = "cluster-2" "principal_arn" = "user3" } }
Ми вже тримаємо 0, 1, … 3:
> { for cluster, user in local.eks_users_access_entries_backend : cluster => user } { "0" = { "cluster_name" = "cluster-1" "principal_arn" = "user1" } "1" = { "cluster_name" = "cluster-1" "principal_arn" = "user3" } "2" = { "cluster_name" = "cluster-2" "principal_arn" = "user1" } "3" = { "cluster_name" = "cluster-2" "principal_arn" = "user3" } }
І якщо раніше for_each
створював map
з ключем “3” і значенням {“cluster_name” = “cluster-2” “principal_arn” = “user1“}, то тепер ключ 3 буде мати значення {“cluster_name” = “cluster-2” “principal_arn” = “user3“}.
І для Terraform це виглядає так, що в об’єкті за тим самим ключем змінилось значення, а тому він має видалити старий ресурс local_file.backend["3"]
– і створити новий за тим самим індексом, але вже з новим змістом:
Terraform will perform the following actions: # local_file.backend["1"] must be replaced -/+ resource "local_file" "backend" { ~ content = <<-EOT # forces replacement cluster_name=cluster-1 - principal_arn=user2 + principal_arn=user3 EOT ... # local_file.backend["2"] must be replaced -/+ resource "local_file" "backend" { ~ content = <<-EOT # forces replacement - cluster_name=cluster-1 + cluster_name=cluster-2 - principal_arn=user3 + principal_arn=user1 EOT ... # local_file.backend["3"] must be replaced -/+ resource "local_file" "backend" { ~ content = <<-EOT # forces replacement cluster_name=cluster-2 - principal_arn=user1 + principal_arn=user3 EOT ...
І все це тому, що for_each
будується на основі нестабільного індексу, який може змінюватись.
The Fix
Отже, як ми можемо цьому запобігти?
Просто змінити те, як для for_each
створюються ключі.
Замість того, аби створювати ключ з індексу та значення у вигляді object, як це робиться зараз:
> { for cluster, user in local.eks_users_access_entries_backend : cluster => user } { "0" = { "cluster_name" = "cluster-1" "principal_arn" = "user1" } ... } "5" = { "cluster_name" = "cluster-2" "principal_arn" = "user3" } }
Ми можемо створити унікальний ключ на кожну пару cluster+user, і виконувати ітерацію за цим ключем:
> { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry } { "cluster-1-user1" = { "cluster_name" = "cluster-1" "principal_arn" = "user1" } "cluster-1-user2" = { "cluster_name" = "cluster-1" "principal_arn" = "user2" } "cluster-1-user3" = { "cluster_name" = "cluster-1" "principal_arn" = "user3" } "cluster-2-user1" = { "cluster_name" = "cluster-2" "principal_arn" = "user1" } "cluster-2-user2" = { "cluster_name" = "cluster-2" "principal_arn" = "user2" } "cluster-2-user3" = { "cluster_name" = "cluster-2" "principal_arn" = "user3" } }
І тоді при видаленні “user2” всі інші ключі в умові для for_each
не зміняться, і він не буде міняти файли:
> { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry } { "cluster-1-user1" = { "cluster_name" = "cluster-1" "principal_arn" = "user1" } "cluster-1-user3" = { "cluster_name" = "cluster-1" "principal_arn" = "user3" } "cluster-2-user1" = { "cluster_name" = "cluster-2" "principal_arn" = "user1" } "cluster-2-user3" = { "cluster_name" = "cluster-2" "principal_arn" = "user3" } }
В коді це буде виглядати так:
resource "local_file" "backend" { for_each = { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry } filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt" content = <<EOF cluster_name=${each.value.cluster_name} principal_arn=${each.value.principal_arn} EOF }
На перший раз Terraform все одно перестворить всі ресурси, бо змінились ключі – але надалі можна буде спокійно додавати/видаляти юзерів.