У нас є автоматизація для 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 все одно перестворить всі ресурси, бо змінились ключі – але надалі можна буде спокійно додавати/видаляти юзерів.