diff --git a/modules/k8s-secret/main.tf b/modules/k8s-secret/main.tf index 000e1af..d050ed6 100644 --- a/modules/k8s-secret/main.tf +++ b/modules/k8s-secret/main.tf @@ -247,6 +247,47 @@ locals { ) } + # Явный контракт для типов с обязательным depends_on. Раньше отсутствующее + # или неполное depends_on тихо резолвилось через try(..., "") в несуществующий + # ключ outputs-карты, outputs_by_secret[name] становился null, и секрет молча + # создавался ПУСТЫМ (без host/port/username/password) - без единой ошибки в + # плане. Для type=yc_sa такой защиты не было вообще: null-источник просто не + # проверялся, и итоговые access_key/secret_key/service_account_id молча + # оставались пустыми строками. Здесь это - явная ошибка плана. Секреты с + # готовым статическим data (adopt/1:1) под контракт не подпадают: они не + # резолвят depends_on вообще. + secrets_contract_errors = { + for name, secret in local.secrets_map : name => ( + length(try(secret.data, {})) > 0 ? "" : + secret.type == "database" || secret.type == "database_url" ? ( + try(secret.depends_on.cluster, "") == "" || try(secret.depends_on.db, "") == "" || try(secret.depends_on.user, "") == "" + ? "type=${secret.type} requires depends_on.cluster, depends_on.db, depends_on.user (got cluster=${try(secret.depends_on.cluster, "")}, db=${try(secret.depends_on.db, "")}, user=${try(secret.depends_on.user, "")})" + : "" + ) : + secret.type == "valkey" ? ( + local.valkey_dependency_keys[name].cluster == "" || local.valkey_dependency_keys[name].user == "" + ? "type=valkey requires depends_on.cluster (or valkey_cluster) and depends_on.user (or valkey_user)" + : "" + ) : + secret.type == "rabbitmq" ? ( + try(secret.depends_on.rabbitmq_vhost, try(secret.depends_on.vhost, "")) == "" || try(secret.depends_on.rabbitmq_user, try(secret.depends_on.user, "")) == "" + ? "type=rabbitmq requires depends_on.vhost (or rabbitmq_vhost) and depends_on.user (or rabbitmq_user)" + : "" + ) : + secret.type == "kafka" ? ( + try(coalesce(secret.depends_on.kafka_ref, secret.depends_on.cluster_ref, secret.depends_on.cluster), "") == "" || try(coalesce(secret.depends_on.kafka_user, secret.depends_on.user), "") == "" + ? "type=kafka requires depends_on.kafka_ref (or cluster_ref/cluster) and depends_on.kafka_user (or user)" + : "" + ) : + secret.type == "yc_sa" ? ( + try(secret.depends_on.service_account, "") == "" + ? "type=yc_sa requires depends_on.service_account" + : "" + ) : + "" + ) + } + # Если для секрета заданы явные значения (data) - используем их 1:1, # иначе собираем data из outputs/custom/constant/random как обычно. secrets_data = { @@ -326,6 +367,10 @@ resource "kubernetes_secret" "with_ignore" { lifecycle { # Игнорируем data, type и metadata чтобы не было phantom updates ignore_changes = [data, type, metadata, wait_for_service_account_token] + precondition { + condition = local.secrets_contract_errors[each.key] == "" + error_message = "Secret contract violation for ${each.key}: ${local.secrets_contract_errors[each.key]}" + } } } @@ -350,6 +395,10 @@ resource "kubernetes_secret" "without_ignore" { lifecycle { # type и metadata игнорируем чтобы не было phantom updates ignore_changes = [type, metadata, wait_for_service_account_token] + precondition { + condition = local.secrets_contract_errors[each.key] == "" + error_message = "Secret contract violation for ${each.key}: ${local.secrets_contract_errors[each.key]}" + } } } @@ -363,4 +412,11 @@ resource "vault_kv_secret_v2" "this" { data_json = jsonencode(merge(each.value.data, { for key, config in each.value.random_keys : key => random_password.secrets["${each.key}:${key}"].result })) + + lifecycle { + precondition { + condition = local.secrets_contract_errors[each.key] == "" + error_message = "Secret contract violation for ${each.key}: ${local.secrets_contract_errors[each.key]}" + } + } }