terraform-contour-mirror/modules/k8s-secret/main.tf

434 lines
20 KiB
HCL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

locals {
# Маппинг наших типов на Kubernetes типы
k8s_secret_type_map = {
"dockerconfigjson" = "kubernetes.io/dockerconfigjson"
"database" = "Opaque"
"database_url" = "Opaque"
"s3" = "Opaque"
"valkey" = "Opaque"
"yc_sa" = "Opaque"
"rabbitmq" = "Opaque"
"kafka" = "Opaque"
"opaque" = "Opaque"
}
default_yc_sa_key_names = {
access_key = "access_key"
secret_key = "secret_key"
service_account_id = "service_account_id"
}
secrets_map = {
for secret in var.secrets : "${secret.namespace}/${secret.name}" => secret
}
random_keys_flat = merge([
for secret_name, secret in local.secrets_map : {
for key, config in try(secret.random_keys, {}) : "${secret_name}:${key}" => {
secret_name = secret_name
key = key
length = try(config.length, 32)
special = try(config.special, false)
}
}
]...)
database_outputs_by_secret = {
for name, secret in local.secrets_map : name => try(
var.database_outputs_map["${try(secret.depends_on.cluster, "")}:${try(secret.depends_on.db, "")}:${try(secret.depends_on.user, "")}"],
null
)
}
valkey_dependency_keys = {
for name, secret in local.secrets_map : name => {
cluster = try(secret.depends_on.valkey_cluster, "") != "" ? try(secret.depends_on.valkey_cluster, "") : try(secret.depends_on.cluster, "")
user = try(secret.depends_on.valkey_user, "") != "" ? try(secret.depends_on.valkey_user, "") : try(secret.depends_on.user, "")
}
}
valkey_outputs_by_secret = {
for name, secret in local.secrets_map : name => try(
var.valkey_outputs_map["${local.valkey_dependency_keys[name].cluster}:${local.valkey_dependency_keys[name].user}"],
null
)
}
rabbitmq_outputs_by_secret = {
for name, secret in local.secrets_map : name => try(
var.rabbitmq_outputs_map[
format(
"%s:%s:%s",
try(secret.depends_on.rabbitmq_ref, "default"),
try(secret.depends_on.rabbitmq_vhost, try(secret.depends_on.vhost, "")),
try(secret.depends_on.rabbitmq_user, try(secret.depends_on.user, ""))
)
],
null
)
}
kafka_outputs_by_secret = {
for name, secret in local.secrets_map : name => try(
var.kafka_outputs_map[
format(
"%s:%s",
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), "")
)
],
null
)
}
rabbitmq_default_credentials_by_secret = {
for name, secret in local.secrets_map : name => local.rabbitmq_outputs_by_secret[name] == null ? {} : {
host = local.rabbitmq_outputs_by_secret[name].host
hostname = "${local.rabbitmq_outputs_by_secret[name].host}:${local.rabbitmq_outputs_by_secret[name].port}"
port = local.rabbitmq_outputs_by_secret[name].port
vhost = local.rabbitmq_outputs_by_secret[name].vhost
user = local.rabbitmq_outputs_by_secret[name].username
username = local.rabbitmq_outputs_by_secret[name].username
password = local.rabbitmq_outputs_by_secret[name].password
uri = local.rabbitmq_outputs_by_secret[name].uri
management_endpoint = local.rabbitmq_outputs_by_secret[name].management_endpoint
}
if secret.type == "rabbitmq"
}
rabbitmq_credentials_by_secret = {
for name, secret in local.secrets_map : name => length(try(secret.credential_keys, {})) == 0 ? local.rabbitmq_default_credentials_by_secret[name] : {
for key, source in try(secret.credential_keys, {}) : key => try(local.rabbitmq_default_credentials_by_secret[name][source], "")
}
if secret.type == "rabbitmq" && local.rabbitmq_outputs_by_secret[name] != null
}
kafka_default_credentials_by_secret = {
for name, secret in local.secrets_map : name => local.kafka_outputs_by_secret[name] == null ? {} : {
host = local.kafka_outputs_by_secret[name].host
hostname = local.kafka_outputs_by_secret[name].hostname
port = local.kafka_outputs_by_secret[name].port
username = local.kafka_outputs_by_secret[name].username
user = local.kafka_outputs_by_secret[name].username
password = local.kafka_outputs_by_secret[name].password
sasl_mechanism = local.kafka_outputs_by_secret[name].sasl_mechanism
security_protocol = local.kafka_outputs_by_secret[name].security_protocol
bootstrap_server = local.kafka_outputs_by_secret[name].bootstrap_server
bootstrap_servers = local.kafka_outputs_by_secret[name].bootstrap_servers
bootstrap_servers_json = local.kafka_outputs_by_secret[name].bootstrap_servers_json
}
if secret.type == "kafka"
}
kafka_credentials_by_secret = {
for name, secret in local.secrets_map : name => length(try(secret.credential_keys, {})) == 0 ? local.kafka_default_credentials_by_secret[name] : {
for key, source in try(secret.credential_keys, {}) : key => try(local.kafka_default_credentials_by_secret[name][source], "")
}
if secret.type == "kafka" && local.kafka_outputs_by_secret[name] != null
}
yc_sa_sources = {
for name, secret in local.secrets_map : name => try(
var.yc_service_accounts_map[try(secret.depends_on.service_account, "")],
null
)
if secret.type == "yc_sa"
}
yc_sa_key_names_by_secret = {
for name, secret in local.secrets_map : name => merge(
local.default_yc_sa_key_names,
try(secret.yc_sa_key_names, {})
)
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)
}
if length(try(secret.data, {})) > 0
}
secrets_data_computed = {
for name, secret in local.secrets_map : name =>
secret.type == "dockerconfigjson" ? {
".dockerconfigjson" = jsonencode({
auths = {
"${secret.registry_url}" = {
username = try(var.env_vars.DOCKER_REGISTRY_USERNAME, "")
password = try(var.env_vars.DOCKER_REGISTRY_PASSWORD, "")
auth = base64encode("${try(var.env_vars.DOCKER_REGISTRY_USERNAME, "")}:${try(var.env_vars.DOCKER_REGISTRY_PASSWORD, "")}")
}
}
})
} : 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)
endpoint = try(var.constants.s3_endpoint, "https://storage.yandexcloud.net")
} : secret.type == "yc_sa" ? merge(
{
(local.yc_sa_key_names_by_secret[name].access_key) = try(local.yc_sa_sources[name].access_key, "")
(local.yc_sa_key_names_by_secret[name].secret_key) = try(local.yc_sa_sources[name].secret_key, "")
(local.yc_sa_key_names_by_secret[name].service_account_id) = try(local.yc_sa_sources[name].service_account_id, "")
endpoint = "https://storage.yandexcloud.net"
},
try(secret.custom_keys, {}),
{
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")
database = local.database_outputs_by_secret[name].database_name
username = local.database_outputs_by_secret[name].user_name
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], "")
}
) : secret.type == "database_url" && local.database_outputs_by_secret[name] != null ? merge(
{
database_url = format(
"postgresql+asyncpg://%s:%s@%s:%s/%s",
local.database_outputs_by_secret[name].user_name,
local.database_outputs_by_secret[name].password,
local.database_outputs_by_secret[name].host,
try(var.constants.postgres_port, "6432"),
local.database_outputs_by_secret[name].database_name
)
},
try(secret.custom_keys, {}),
{
for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "")
}
) : secret.type == "valkey" && local.valkey_outputs_by_secret[name] != null ? merge(
{
cert = try(var.constants.valkey_ca, try(var.constants.postgres_ca, ""))
host = local.valkey_outputs_by_secret[name].host
login = local.valkey_outputs_by_secret[name].user_name
password = local.valkey_outputs_by_secret[name].password
port = tostring(try(local.valkey_outputs_by_secret[name].port, try(var.constants.valkey_port, "6380")))
url = format("%s:%s", local.valkey_outputs_by_secret[name].host, tostring(try(local.valkey_outputs_by_secret[name].port, try(var.constants.valkey_port, "6380"))))
},
try(secret.custom_keys, {}),
{
for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "")
}
) : secret.type == "rabbitmq" && local.rabbitmq_outputs_by_secret[name] != null ? merge(
local.rabbitmq_credentials_by_secret[name],
try(secret.custom_keys, {}),
{
for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "")
}
) : secret.type == "kafka" && local.kafka_outputs_by_secret[name] != null ? merge(
local.kafka_credentials_by_secret[name],
try(secret.custom_keys, {}),
{
for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "")
}
) : merge(
try(secret.custom_keys, {}),
{
for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "")
}
)
}
# Явный контракт для типов с обязательным 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"
: ""
) :
# 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"
: ""
) :
""
)
}
# Если для секрета заданы явные значения (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)
? local.secrets_data_override[name]
: local.secrets_data_computed[name]
)
}
secrets_with_data = {
for name, secret in local.secrets_map : name => {
name = secret.name
namespace = secret.namespace
type = secret.type
k8s_type = try(local.k8s_secret_type_map[secret.type], "Opaque")
labels = try(secret.labels, {})
annotations = try(secret.annotations, {})
ignore_changes = try(secret.ignore_changes, false)
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
}
secrets_without_ignore = {
for name, secret in local.secrets_with_data : name => secret
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"
}
}
resource "random_password" "secrets" {
for_each = local.random_keys_flat
length = each.value.length
special = each.value.special
upper = true
lower = true
numeric = true
lifecycle {
ignore_changes = all
}
}
resource "kubernetes_secret" "with_ignore" {
for_each = local.secrets_with_ignore
metadata {
name = each.value.name
namespace = each.value.namespace
labels = try(each.value.labels, {})
annotations = try(each.value.annotations, {})
}
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] == ""
error_message = "Secret contract violation for ${each.key}: ${local.secrets_contract_errors[each.key]}"
}
}
}
resource "kubernetes_secret" "without_ignore" {
for_each = local.secrets_without_ignore
metadata {
name = each.value.name
namespace = each.value.namespace
labels = try(each.value.labels, {})
annotations = try(each.value.annotations, {})
}
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] == ""
error_message = "Secret contract violation for ${each.key}: ${local.secrets_contract_errors[each.key]}"
}
}
}
# Vault-sink: same resolved (plaintext) data map, written to Vault KV v2 instead
# of a Kubernetes Secret. Path = secret.vault_path (default <prefix>/<name>).
resource "vault_kv_secret_v2" "this" {
for_each = local.secrets_vault
mount = var.vault_kv_mount
name = each.value.vault_path
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]}"
}
}
}