++ service contract compiler, issues on brusnika-stage
All checks were successful
Terraform Terragrunt (contour) / validate (push) Successful in 59s
Terraform Terragrunt (contour) / plan (push) Successful in 1m5s
Terraform Terragrunt (contour) / apply (push) Successful in 1m56s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kochetkov S 2026-08-12 16:17:33 +03:00
parent 78316216b3
commit 0b7545670f
12 changed files with 346 additions and 5 deletions

View File

@ -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

View File

@ -0,0 +1,10 @@
namespaces:
issues:
owner: platform
class: default
services:
- issues
# namespace создан руками и живёт вне terraform, запись нужна только чтобы
# стек secrets знал, куда класть rabbitmq и regcred
manage: false
image_pull_secret: true

View File

@ -0,0 +1,7 @@
service: issues
owner: platform
needs:
rabbitmq:
vhosts:
- issues

135
live/_shared/config.hcl Normal file
View File

@ -0,0 +1,135 @@
# Единственное место загрузки контракта сервисов (environments/<env>/*.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/<env>/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 пуст -> [<service>]; первый vhost основной, его секрет зовётся просто
# rabbitmq, остальные получают суффикс - хвост после "<service>-".
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)
}

View File

@ -0,0 +1,44 @@
include "root" {
path = find_in_parent_folders()
}
# Юнит-валидатор контракта: ноль ресурсов, планируется первым (по алфавиту
# contracts < database < ... в scripts/run_all_stacks.sh). Для окружений без
# environments/<env>/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
}

View File

@ -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", "")
}

View File

@ -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, [])

View File

@ -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.

65
modules/contracts/main.tf Normal file
View File

@ -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/<name>.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://<path>#<field>
bad_credentials_ref = (
local.registry != null && !can(regex("^vault://[^#]+#.+$", local.credentials_ref))
) ? ["environment.registry.credentials_ref '${local.credentials_ref}' не соответствует формату vault://<path>#<field>"] : []
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)}") : ""
}

View File

@ -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))
}

View File

@ -0,0 +1,23 @@
# Валидатор контракта. Компиляция живёт в live/_shared/config.hcl, здесь её нет
# намеренно: одна и та же логика в двух местах неизбежно разъедется.
#
# Типы намеренно any - модуль не создаёт ресурсов и не должен падать на
# несовпадении формы раньше, чем выдаст человекочитаемую ошибку валидации.
variable "environment" {
description = "environments/<env>/environment.yaml как есть"
type = any
default = {}
}
variable "namespaces" {
description = "environments/<env>/namespaces.yaml, ключ namespaces"
type = any
default = {}
}
variable "services" {
description = "environments/<env>/services/*.yaml, ключ карты - имя файла без расширения"
type = any
default = {}
}

View File

@ -0,0 +1,3 @@
terraform {
required_version = ">= 1.5.0"
}