diff --git a/live/secrets/terragrunt.hcl b/live/secrets/terragrunt.hcl index eea02d7..25b6ff1 100644 --- a/live/secrets/terragrunt.hcl +++ b/live/secrets/terragrunt.hcl @@ -15,30 +15,14 @@ locals { local.env_from_file != "" ? local.env_from_file : local.env_fallback ) - # Single declarative source: shape + data in one sops file. In CI it is - # decrypted ahead of time and the path is passed via INFRA_SECRET_VALUES_FILE; - # locally we fall back to direct sops decryption. Values reach the module only - # via inputs (ephemeral), never written to disk. - # - # No try(...,"")/try(...,{}) on decrypt/parse/env-lookup below on purpose: - # swallowing those failures used to collapse silently into secrets = [], - # and a terraform plan with 0 declared secrets looks like a normal, boring - # "destroy everything" diff - nothing marks it as "decrypt actually failed". secret_values_env_file = get_env("INFRA_SECRET_VALUES_FILE", "") secret_values_raw = local.secret_values_env_file != "" ? file(local.secret_values_env_file) : sops_decrypt_file("${local.repo_root}/infrastructure-secrets.yaml") secret_values_config = yamldecode(local.secret_values_raw) - # file() as a poor man's error(): HCL has no native assert/error function, - # this is the standard trick - an intentionally-broken file() call surfaces - # the message text inside terraform's own "no such file" error. _fail_on_missing_secret_env = !contains(keys(local.secret_values_config.environments), local.env_name) ? file("INFRA_ENV '${local.env_name}' not found in infrastructure-secrets.yaml environments: [${join(", ", keys(local.secret_values_config.environments))}]") : "" secret_values_env = local.secret_values_config.environments[local.env_name] - # A typo in the "secrets" key itself (e.g. "secretz") opens the same hole - # as the per-entry typos below: try() on the secrets line below can't tell - # "key absent because 0 secrets declared" from "key renamed by a typo" - - # both yield []. Explicitly validate the env's allowed top-level keys. allowed_secret_env_keys = toset(["secrets", "vault"]) secret_env_key_typos = [ for k in keys(local.secret_values_env) : "env '${local.env_name}': unknown top-level key '${k}'" @@ -46,15 +30,8 @@ locals { ] _fail_on_secret_env_key_typos = length(local.secret_env_key_typos) > 0 ? file("Unknown/misspelled top-level key(s) in infrastructure-secrets.yaml: ${join(" | ", local.secret_env_key_typos)}") : "" - # secrets key itself may legitimately be absent for an environment with 0 - # secrets declared - that one stays a real default, not an error. secrets = try(local.secret_values_env.secrets, []) - # Явная проверка top-level ключей каждой записи secrets: Terraform молча - # отбрасывает незнакомые ключи при приведении к типу (например custom_key - # вместо custom_keys, depends_ons вместо depends_on) - без этой проверки - # опечатка в infrastructure-secrets.yaml тихо теряется, без единой ошибки - # в плане, и нужное поле остаётся дефолтным/пустым. allowed_secret_keys = toset([ "name", "namespace", "type", "registry_url", "data", "depends_on", "yc_sa_key_names", "custom_keys", "constant_keys", "random_keys", @@ -68,11 +45,6 @@ locals { ]) _fail_on_secret_key_typos = length(local.secret_key_typos) > 0 ? file("Unknown/misspelled key(s) in infrastructure-secrets.yaml secrets: ${join(" | ", local.secret_key_typos)}") : "" - # image-pull-secret (regcred) fan-out. Single source: vault.data.common.regcred.dockerconfigjson - # (secret values live under vault.data.* - see .sops.yaml encrypted_regex). - # Rolled as a kubernetes.io/dockerconfigjson secret named `regcred` into every - # namespace flagged `image_pull_secret: true` in infrastructure.yaml. Only created - # when the source is a real docker config (guards against the CHANGE_ME placeholder). regcred_dockerconfigjson = try(local.secret_values_env.vault.data.common.regcred.dockerconfigjson, "") regcred_valid = can(jsondecode(local.regcred_dockerconfigjson)) regcred_namespaces = [ @@ -116,7 +88,6 @@ locals { labels = try(secret.labels, {}) annotations = try(secret.annotations, {}) ignore_changes = try(secret.lifecycle.ignore_changes, false) - # Sink: k8s (default) or vault. vault_path is the KV name (default /). sink = try(secret.sink, "k8s") vault_path = try(secret.vault_path, "") } @@ -194,7 +165,6 @@ inputs = { kafka_outputs_map = try(dependency.kafka_topics.outputs.kafka_outputs_map, {}) valkey_outputs_map = {} - # In-cluster constants (contour). No managed-YC CA; in-cluster pgbouncer/pg on 5432. constants = { s3_endpoint = try(local.infra_config.environments[local.env_name].minio.endpoint, "http://minio.minio.svc.cluster.local:9000") postgres_port = "5432" @@ -203,7 +173,6 @@ inputs = { valkey_ca = "" } - # Vault sink target (KV v2). Mount/prefix match the vault-secrets platform layout. vault_kv_mount = try(local.secret_values_env.vault.kv_mount, "secrets") vault_path_prefix = try(local.secret_values_env.vault.path_prefix, "vault") diff --git a/live/vault-secrets/terragrunt.hcl b/live/vault-secrets/terragrunt.hcl index 6174b06..d0197fc 100644 --- a/live/vault-secrets/terragrunt.hcl +++ b/live/vault-secrets/terragrunt.hcl @@ -7,15 +7,6 @@ locals { infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml")) secrets_file = "${local.repo_root}/infrastructure-secrets.yaml" - # В CI файл расшифровывается заранее одним шагом, и путь передаётся через - # INFRA_SECRET_VALUES_FILE (SOPS_AGE_KEY нужен только этому шагу, не всей - # джобе). Локально - fallback на прямую расшифровку через sops_decrypt_file. - # - # Без try(...,"")/try(...,{}) на decrypt/parse ниже намеренно: раньше - # провалившаяся расшифровка тихо схлопывалась в secrets_config = {}, дальше - # в vault_config = {} и vault_enabled = false - план "ничего не менять в - # vault" выглядел бы легитимно, без единой ошибки, хотя причина - сломанный - # ключ/файл, а не осознанное отсутствие vault-конфига. secret_values_env_file = get_env("INFRA_SECRET_VALUES_FILE", "") secrets_config_raw = local.secret_values_env_file != "" ? file(local.secret_values_env_file) : sops_decrypt_file(local.secrets_file) secrets_config = yamldecode(local.secrets_config_raw) @@ -29,17 +20,10 @@ locals { local.env_from_file != "" ? local.env_from_file : local.env_fallback ) - # file() как самодельный error(): в HCL нет нативного assert/error, это - # стандартный трюк - заведомо не существующий путь всплывает в тексте - # ошибки terraform "no such file". _fail_on_missing_secret_env = !contains(keys(local.secrets_config.environments), local.env_name) ? file("INFRA_ENV '${local.env_name}' not found in infrastructure-secrets.yaml environments: [${join(", ", keys(local.secrets_config.environments))}]") : "" env_config = local.secrets_config.environments[local.env_name] - # Опечатка в самом ключе "vault" (например "vaultt") даёт ту же дыру, что - # и провалившийся decrypt: try() ниже не отличит "vault осознанно не задан" - # от "ключ переименован опечаткой" - в обоих случаях vault_config = {} и - # план "нечего менять в vault" выглядит легитимно. allowed_env_config_keys = toset(["secrets", "vault"]) env_config_key_typos = [ for k in keys(local.env_config) : "env '${local.env_name}': unknown top-level key '${k}'" @@ -79,9 +63,6 @@ terraform { inputs = { vault_kv_mount = try(local.vault_config.kv_mount, "secrets") path_prefix = try(local.vault_config.path_prefix, "vault") - # Секретные значения обёрнуты под vault.data.* (см. .sops.yaml) - шифруется - # только этот поддерево, а не весь файл. applications ниже - конфиг policy/ - # role, секретов не содержит, поэтому вне data. common = try(local.vault_config.data.common, {}) infra = try(local.vault_config.data.infrastructure, {}) applications = try(local.vault_config.applications, {}) diff --git a/modules/k8s-secret/main.tf b/modules/k8s-secret/main.tf index d06bc4f..d8c18b1 100644 --- a/modules/k8s-secret/main.tf +++ b/modules/k8s-secret/main.tf @@ -1,7 +1,6 @@ locals { - # Маппинг наших типов на Kubernetes типы k8s_secret_type_map = { "dockerconfigjson" = "kubernetes.io/dockerconfigjson" "database" = "Opaque" @@ -145,9 +144,6 @@ locals { if secret.type == "yc_sa" } - # Явные значения секрета (adopt 1:1 / статические). Приходят прямо из - # infrastructure-secrets.yaml в поле data как map(key => base64(raw_value)). - # k8s хранит .data уже в base64, поэтому base64decode даёт исходное значение байт-в-байт. secrets_data_override = { for name, secret in local.secrets_map : name => { for key, b64 in try(secret.data, {}) : key => base64decode(b64) @@ -168,7 +164,6 @@ locals { } }) } : secret.type == "s3" ? { - # Если указан конкретный bucket в depends_on - используем его, иначе fallback на s3_outputs access_key = try(var.s3_buckets_map[secret.depends_on.bucket].access_key, var.s3_outputs.access_key) secret_key = try(var.s3_buckets_map[secret.depends_on.bucket].secret_key, var.s3_outputs.secret_key) bucket = try(var.s3_buckets_map[secret.depends_on.bucket].bucket_name, var.s3_outputs.bucket_name) @@ -185,7 +180,6 @@ locals { for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "") } ) : secret.type == "database" && local.database_outputs_by_secret[name] != null ? merge( - # Базовые поля из database outputs { host = local.database_outputs_by_secret[name].host port = try(var.constants.postgres_port, "6432") @@ -194,7 +188,6 @@ locals { password = local.database_outputs_by_secret[name].password "ca.crt" = try(var.constants.postgres_ca, "") }, - # Кастомные статические поля try(secret.custom_keys, {}), { for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "") @@ -247,15 +240,6 @@ 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 ? "" : @@ -284,12 +268,6 @@ locals { ? "type=yc_sa requires depends_on.service_account" : "" ) : - # dockerconfigjson не зависит от per-secret depends_on - источник это - # МОДУЛЬНЫЕ (общие для всех секретов) env_vars.DOCKER_REGISTRY_USERNAME/ - # PASSWORD. Если они пустые, секрет молча создаётся с пустым логином/ - # паролем внутри .dockerconfigjson - тот же класс тихой пустышки. - # ignore_changes=true - осознанно замороженный секрет (напр. известно - # пустые креды в CI) - контракт его не блокирует. secret.type == "dockerconfigjson" && !try(secret.ignore_changes, false) ? ( trimspace(try(var.env_vars.DOCKER_REGISTRY_USERNAME, "")) == "" || trimspace(try(var.env_vars.DOCKER_REGISTRY_PASSWORD, "")) == "" ? "type=dockerconfigjson requires module-level env_vars.DOCKER_REGISTRY_USERNAME and env_vars.DOCKER_REGISTRY_PASSWORD to be non-empty" @@ -299,8 +277,6 @@ locals { ) } - # Если для секрета заданы явные значения (data) - используем их 1:1, - # иначе собираем data из outputs/custom/constant/random как обычно. secrets_data = { for name, secret in local.secrets_map : name => ( contains(keys(local.secrets_data_override), name) @@ -321,12 +297,10 @@ locals { sink = try(secret.sink, "k8s") vault_path = try(secret.vault_path, "") != "" ? secret.vault_path : "${var.vault_path_prefix}/${secret.name}" data = try(local.secrets_data[name], {}) - # Явный data имеет приоритет над вычисляемыми, включая случайные ключи. random_keys = contains(keys(local.secrets_data_override), name) ? {} : try(secret.random_keys, {}) } } - # k8s-sink secrets, split by ignore_changes semantics. secrets_with_ignore = { for name, secret in local.secrets_with_data : name => secret if secret.sink == "k8s" && secret.ignore_changes @@ -336,7 +310,6 @@ locals { if secret.sink == "k8s" && !secret.ignore_changes } - # vault-sink secrets: same resolved data, written to Vault KV v2 instead. secrets_vault = { for name, secret in local.secrets_with_data : name => secret if secret.sink == "vault" @@ -369,14 +342,11 @@ resource "kubernetes_secret" "with_ignore" { type = each.value.k8s_type - # Ссылка на random_password остаётся на уровне конкретного секрета. Поэтому - # новый случайный ключ не делает data неизвестным у всех остальных секретов. data = merge(each.value.data, { for key, config in each.value.random_keys : key => random_password.secrets["${each.key}:${key}"].result }) lifecycle { - # Игнорируем data, type и metadata чтобы не было phantom updates ignore_changes = [data, type, metadata, wait_for_service_account_token] precondition { condition = local.secrets_contract_errors[each.key] == "" @@ -397,14 +367,11 @@ resource "kubernetes_secret" "without_ignore" { type = each.value.k8s_type - # Ссылка на random_password остаётся на уровне конкретного секрета. Поэтому - # новый случайный ключ не делает data неизвестным у всех остальных секретов. data = merge(each.value.data, { for key, config in each.value.random_keys : key => random_password.secrets["${each.key}:${key}"].result }) lifecycle { - # type и metadata игнорируем чтобы не было phantom updates ignore_changes = [type, metadata, wait_for_service_account_token] precondition { condition = local.secrets_contract_errors[each.key] == "" @@ -413,8 +380,6 @@ resource "kubernetes_secret" "without_ignore" { } } -# Vault-sink: same resolved (plaintext) data map, written to Vault KV v2 instead -# of a Kubernetes Secret. Path = secret.vault_path (default /). resource "vault_kv_secret_v2" "this" { for_each = local.secrets_vault