diff --git a/environments/brusnika-stage/environment.yaml b/environments/brusnika-stage/environment.yaml new file mode 100644 index 0000000..9c1d05a --- /dev/null +++ b/environments/brusnika-stage/environment.yaml @@ -0,0 +1,30 @@ +name: brusnika-stage + +backends: + rabbitmq: + cluster: default + amqp_host: rabbitmq.rabbitmq.svc.cluster.local + amqp_port: 5672 + management_endpoint: http://rabbitmq.rabbitmq.svc.cluster.local:15672 + vault: + kv_mount: secrets + # не нормативный дефолт apps - значение обязано совпадать с + # environments.brusnika-stage.vault.path_prefix в infrastructure-secrets.yaml, + # откуда его берёт live/secrets + path_prefix: vault + +registry: + url: cr.yandex/crp3ccidau046kdj8g9q + credentials_ref: "vault://vault/common/regcred#.dockerconfigjson" + +policy: + rabbitmq: + allow_create: true + allow_delete: false + +classes: + namespace: + default: + cpu: "4" + memory: 8Gi + pods: 40 diff --git a/environments/brusnika-stage/namespaces.yaml b/environments/brusnika-stage/namespaces.yaml new file mode 100644 index 0000000..810ca49 --- /dev/null +++ b/environments/brusnika-stage/namespaces.yaml @@ -0,0 +1,10 @@ +namespaces: + issues: + owner: platform + class: default + services: + - issues + # namespace создан руками и живёт вне terraform, запись нужна только чтобы + # стек secrets знал, куда класть rabbitmq и regcred + manage: false + image_pull_secret: true diff --git a/environments/brusnika-stage/services/issues.yaml b/environments/brusnika-stage/services/issues.yaml new file mode 100644 index 0000000..87ab8f8 --- /dev/null +++ b/environments/brusnika-stage/services/issues.yaml @@ -0,0 +1,7 @@ +service: issues +owner: platform + +needs: + rabbitmq: + vhosts: + - issues diff --git a/live/_shared/config.hcl b/live/_shared/config.hcl new file mode 100644 index 0000000..edb7e4e --- /dev/null +++ b/live/_shared/config.hcl @@ -0,0 +1,135 @@ +# Единственное место загрузки контракта сервисов (environments//*.yaml). +# +# Файл называется config.hcl, а НЕ terragrunt.hcl, намеренно: и +# scripts/run_all_stacks.sh, и scripts/generate-pipeline.sh ищут юниты строго по +# имени terragrunt.hcl, поэтому этот файл юнитом не считается и сам по себе не +# прогоняется. +# +# Стеки подключают его через read_terragrunt_config() и берут local.<...>. +# Именно read_terragrunt_config, а не dependency: dependency не виден из блока +# locals, и попытка протащить компилятор через него уже роняла пайплайн. +# +# Окружение считается переехавшим на новый формат, только если у него есть +# environments//environment.yaml. Для остальных контуров всё ниже +# вырождается в пустые списки, и concat на стороне стеков становится +# тождественной операцией. + +locals { + repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../..") + + infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml")) + env_override = trimspace(get_env("INFRA_ENV", "")) + env_from_file = try(trimspace(local.infra_config.current_environment), "") + env_name = local.env_override != "" ? local.env_override : ( + local.env_from_file != "" ? local.env_from_file : try(sort(keys(try(local.infra_config.environments, {})))[0], "") + ) + + env_dir = "${local.repo_root}/environments/${local.env_name}" + + # try(), а не тернарник по fileexists: в HCL обе ветки тернарника обязаны + # иметь совместимые типы, а пустой объект против объекта с backends их не + # имеет - "Inconsistent conditional result types". + environment = try(yamldecode(file("${local.env_dir}/environment.yaml")), {}) + namespaces = try(yamldecode(file("${local.env_dir}/namespaces.yaml")).namespaces, {}) + + service_files = try(fileset(local.env_dir, "services/*.yaml"), []) + services = { + for f in local.service_files : + trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.env_dir}/${f}")) + } + + registry = try(local.environment.registry, null) + credentials_ref = try(local.registry.credentials_ref, "") + + # ── вывод сущностей из имени сервиса ─────────────────────────────────────── + # vhosts пуст -> []; первый vhost основной, его секрет зовётся просто + # rabbitmq, остальные получают суффикс - хвост после "-". + service_vhosts = { + for name, svc in local.services : + name => length(try(svc.needs.rabbitmq.vhosts, [])) > 0 ? svc.needs.rabbitmq.vhosts : [name] + if try(svc.needs.rabbitmq, null) != null + } + + # сервис размещён во всех namespace, где он перечислен в services[] + service_namespaces = { + for name, _ in local.services : + name => sort([for ns_key, ns in local.namespaces : ns_key if contains(try(ns.services, []), name)]) + } + + # ── inputs для modules/k8s-namespace ─────────────────────────────────────── + namespaces_input = [ + for ns_key, ns in local.namespaces : { + name = ns_key + labels = try(ns.labels, {}) + annotations = try(ns.annotations, {}) + manage = try(ns.manage, true) + image_pull_secret = try(ns.image_pull_secret, true) + } + ] + + # ── inputs для modules/rabbitmq ──────────────────────────────────────────── + rabbitmq_vhosts = [ + for v in sort(distinct(flatten([for _, vhosts in local.service_vhosts : vhosts]))) : { name = v } + ] + + rabbitmq_users = [ + for name, _ in local.service_vhosts : { name = name, password_length = 32, password_special = false } + ] + + # владелец получает полные права в каждом своём vhost + rabbitmq_permissions = flatten([ + for name, vhosts in local.service_vhosts : [ + for v in vhosts : { user = name, vhost = v, configure = ".*", write = ".*", read = ".*" } + ] + ]) + + # ── секреты в форме v2-контракта ─────────────────────────────────────────── + # Отдаём именно записи с targets, а не плоский список: live/secrets вливает их + # ДО собственного разбора v2, поэтому вся его машинерия (валидация ключей, + # вывод type из source.kind, разворачивание targets) работает без изменений. + secret_prefix = { + for name, _ in local.services : name => join("", [ + for ns_key in local.service_namespaces[name] : + try(local.namespaces[ns_key].secret_prefix, "") == "service" ? "${name}-" : "" + ]) + } + + rabbitmq_secrets = flatten([ + for name, vhosts in local.service_vhosts : [ + for ns_key in local.service_namespaces[name] : [ + for idx, v in vhosts : { + id = "${name}-rabbitmq-${ns_key}-${v}" + schema = "rabbitmq.v1" + namespace = ns_key + ownership = "managed" + source = { + kind = "rabbitmq_user" + ref = { vhost = v, user = name } + } + targets = [{ + kind = "kubernetes" + namespace = ns_key + name = "${try(local.namespaces[ns_key].secret_prefix, "") == "service" ? "${name}-" : ""}rabbitmq${idx == 0 ? "" : "-${trimprefix(v, "${name}-")}"}" + }] + } + ] + ] + ]) + + regcred_secrets = local.credentials_ref == "" ? [] : [ + for ns_key, ns in local.namespaces : { + id = "${ns_key}-regcred" + schema = "dockerconfigjson.v1" + namespace = ns_key + ownership = "referenced" + source = { + kind = "secret_ref" + ref = local.credentials_ref + } + targets = [{ kind = "kubernetes", namespace = ns_key, name = "regcred" }] + } + if try(ns.image_pull_secret, true) + ] + + secrets_v2 = concat(local.regcred_secrets, local.rabbitmq_secrets) +} diff --git a/live/contracts/terragrunt.hcl b/live/contracts/terragrunt.hcl new file mode 100644 index 0000000..6ac3ca1 --- /dev/null +++ b/live/contracts/terragrunt.hcl @@ -0,0 +1,44 @@ +include "root" { + path = find_in_parent_folders() +} + +# Юнит-валидатор контракта: ноль ресурсов, планируется первым (по алфавиту +# contracts < database < ... в scripts/run_all_stacks.sh). Для окружений без +# environments//environment.yaml на входе пустота, все правила проходят, +# ресурсов нет. + +locals { + repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../..") + shared = read_terragrunt_config("${local.repo_root}/live/_shared/config.hcl") +} + +remote_state { + backend = "s3" + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } + config = { + endpoint = get_env("TF_STATE_S3_ENDPOINT", "") + bucket = get_env("TF_STATE_S3_BUCKET", "") + key = "${local.shared.locals.env_name}/contracts/terraform.tfstate" + region = get_env("TF_STATE_S3_REGION", "ru-central1") + access_key = get_env("S3_ACCESS_KEY", get_env("AWS_ACCESS_KEY_ID", "")) + secret_key = get_env("S3_SECRET_KEY", get_env("AWS_SECRET_ACCESS_KEY", "")) + skip_region_validation = true + skip_credentials_validation = true + skip_metadata_api_check = true + skip_bucket_root_access = true + force_path_style = true + } +} + +terraform { + source = "${get_terragrunt_dir()}/../../modules//contracts" +} + +inputs = { + environment = local.shared.locals.environment + namespaces = local.shared.locals.namespaces + services = local.shared.locals.services +} diff --git a/live/namespace/terragrunt.hcl b/live/namespace/terragrunt.hcl index 7d57ac9..aadfd44 100644 --- a/live/namespace/terragrunt.hcl +++ b/live/namespace/terragrunt.hcl @@ -16,6 +16,9 @@ locals { ) env_config = try(local.infra_config.environments[local.env_name], {}) + + # компилятор контракта; для непереехавших окружений отдаёт пустые списки + shared = read_terragrunt_config("${local.repo_root}/live/_shared/config.hcl") } remote_state { @@ -44,7 +47,7 @@ terraform { } inputs = { - namespaces = try(local.env_config.namespaces, []) + namespaces = concat(try(local.env_config.namespaces, []), local.shared.locals.namespaces_input) kubeconfig_path = get_env("KUBECONFIG", "") kube_context = get_env("KUBE_CONTEXT", "") } diff --git a/live/rabbitmq/terragrunt.hcl b/live/rabbitmq/terragrunt.hcl index 9e4ddef..0037470 100644 --- a/live/rabbitmq/terragrunt.hcl +++ b/live/rabbitmq/terragrunt.hcl @@ -23,6 +23,9 @@ locals { # 127.0.0.1:15672 вместо брокера. management_endpoint = trimspace(get_env("RABBITMQ_ENDPOINT", "")) != "" ? trimspace(get_env("RABBITMQ_ENDPOINT", "")) : "http://rabbitmq.rabbitmq.svc.cluster.local:15672" management_username = trimspace(get_env("RABBITMQ_ADMIN_USER", "")) != "" ? trimspace(get_env("RABBITMQ_ADMIN_USER", "")) : "admin" + + # компилятор контракта; для непереехавших окружений отдаёт пустые списки + shared = read_terragrunt_config("${local.repo_root}/live/_shared/config.hcl") } remote_state { @@ -61,9 +64,9 @@ inputs = { amqp_host = try(local.env_config.rabbitmq.amqp_host, "rabbitmq.rabbitmq.svc.cluster.local") amqp_port = try(local.env_config.rabbitmq.amqp_port, 5672) - vhosts = try(local.env_config.rabbitmq.vhosts, []) - users = try(local.env_config.rabbitmq.users, []) - permissions = try(local.env_config.rabbitmq.permissions, []) + vhosts = concat(try(local.env_config.rabbitmq.vhosts, []), local.shared.locals.rabbitmq_vhosts) + users = concat(try(local.env_config.rabbitmq.users, []), local.shared.locals.rabbitmq_users) + permissions = concat(try(local.env_config.rabbitmq.permissions, []), local.shared.locals.rabbitmq_permissions) topic_permissions = try(local.env_config.rabbitmq.topic_permissions, []) exchanges = try(local.env_config.rabbitmq.exchanges, []) queues = try(local.env_config.rabbitmq.queues, []) diff --git a/live/secrets/terragrunt.hcl b/live/secrets/terragrunt.hcl index ab2f9bc..65ddfb1 100644 --- a/live/secrets/terragrunt.hcl +++ b/live/secrets/terragrunt.hcl @@ -30,7 +30,11 @@ 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 = try(local.secret_values_env.secrets, []) + # компилятор контракта; для непереехавших окружений отдаёт пустой список. + # Вливаем ДО разбора v2 - тогда валидация ключей, вывод type из source.kind и + # разворачивание targets ниже работают поверх его записей без единой правки. + shared = read_terragrunt_config("${local.repo_root}/live/_shared/config.hcl") + secrets = concat(try(local.secret_values_env.secrets, []), local.shared.locals.secrets_v2) # v2 contract detection: a record is v2 if it carries any v2-only key. # Everything else is legacy v1 and keeps working exactly as before. diff --git a/modules/contracts/main.tf b/modules/contracts/main.tf new file mode 100644 index 0000000..cc618d9 --- /dev/null +++ b/modules/contracts/main.tf @@ -0,0 +1,65 @@ +locals { + registry = try(var.environment.registry, null) + credentials_ref = try(local.registry.credentials_ref, "") + + service_namespaces = { + for name, _ in var.services : + name => [for ns_key, ns in var.namespaces : ns_key if contains(try(ns.services, []), name)] + } + + # 1. сервис перечислен в каталоге, но файла services/.yaml нет + catalog_without_file = flatten([ + for ns_key, ns in var.namespaces : [ + for name in try(ns.services, []) : + "namespace '${ns_key}': сервис '${name}' указан в services[], но файла services/${name}.yaml нет" + if !contains(keys(var.services), name) + ] + ]) + + # 2. файл есть, но сервис не размещён ни в одном namespace + file_without_placement = [ + for name, placements in local.service_namespaces : + "services/${name}.yaml: сервис не размещён ни в одном namespace" + if length(placements) == 0 + ] + + # 3. поле service: не совпадает с именем файла + name_mismatch = [ + for name, svc in var.services : + "services/${name}.yaml: поле 'service: ${try(svc.service, "")}' не совпадает с именем файла '${name}'" + if try(svc.service, "") != name + ] + + # 4. два и более сервиса в одном namespace без secret_prefix - иначе секреты + # разных сервисов молча перетрут друг друга по одинаковым именам + missing_secret_prefix = [ + for ns_key, ns in var.namespaces : + "namespace '${ns_key}': сервисов ${length(try(ns.services, []))} (${join(", ", try(ns.services, []))}), но secret_prefix не задан" + if length(try(ns.services, [])) > 1 && trimspace(try(ns.secret_prefix, "")) == "" + ] + + # 5. image_pull_secret: true, а registry в окружении не объявлен + pull_secret_without_registry = [ + for ns_key, ns in var.namespaces : + "namespace '${ns_key}': image_pull_secret true, но environment.registry не задан" + if try(ns.image_pull_secret, true) && local.registry == null + ] + + # 6. credentials_ref не соответствует формату vault://# + bad_credentials_ref = ( + local.registry != null && !can(regex("^vault://[^#]+#.+$", local.credentials_ref)) + ) ? ["environment.registry.credentials_ref '${local.credentials_ref}' не соответствует формату vault://#"] : [] + + validation_errors = concat( + local.catalog_without_file, + local.file_without_placement, + local.name_mismatch, + local.missing_secret_prefix, + local.pull_secret_without_registry, + local.bad_credentials_ref, + ) + + # file() на несуществующем пути - единственный способ уронить plan с текстом + # ошибки в терраформе без ресурсов и без внешних зависимостей + _fail = length(local.validation_errors) > 0 ? file("НАРУШЕН КОНТРАКТ СЕРВИСОВ: ${join(" | ", local.validation_errors)}") : "" +} diff --git a/modules/contracts/outputs.tf b/modules/contracts/outputs.tf new file mode 100644 index 0000000..ae2c288 --- /dev/null +++ b/modules/contracts/outputs.tf @@ -0,0 +1,14 @@ +output "validation_errors" { + description = "пусто, если все шесть правил прошли - при ошибке юнит и так падает на file()" + value = local.validation_errors +} + +output "services" { + description = "имена сервисов, прошедших валидацию" + value = sort(keys(var.services)) +} + +output "namespaces" { + description = "namespace каталога окружения" + value = sort(keys(var.namespaces)) +} diff --git a/modules/contracts/variables.tf b/modules/contracts/variables.tf new file mode 100644 index 0000000..25e8c83 --- /dev/null +++ b/modules/contracts/variables.tf @@ -0,0 +1,23 @@ +# Валидатор контракта. Компиляция живёт в live/_shared/config.hcl, здесь её нет +# намеренно: одна и та же логика в двух местах неизбежно разъедется. +# +# Типы намеренно any - модуль не создаёт ресурсов и не должен падать на +# несовпадении формы раньше, чем выдаст человекочитаемую ошибку валидации. + +variable "environment" { + description = "environments//environment.yaml как есть" + type = any + default = {} +} + +variable "namespaces" { + description = "environments//namespaces.yaml, ключ namespaces" + type = any + default = {} +} + +variable "services" { + description = "environments//services/*.yaml, ключ карты - имя файла без расширения" + type = any + default = {} +} diff --git a/modules/contracts/versions.tf b/modules/contracts/versions.tf new file mode 100644 index 0000000..fd126d1 --- /dev/null +++ b/modules/contracts/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 1.5.0" +}