mirror of
https://gitlab.sarex.io/infra/terraform-contour-mirror.git
synced 2026-10-07 13:51:36 +03:00
++ service contract compiler, issues on brusnika-stage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
78316216b3
commit
0b7545670f
30
environments/brusnika-stage/environment.yaml
Normal file
30
environments/brusnika-stage/environment.yaml
Normal 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
|
||||
10
environments/brusnika-stage/namespaces.yaml
Normal file
10
environments/brusnika-stage/namespaces.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
namespaces:
|
||||
issues:
|
||||
owner: platform
|
||||
class: default
|
||||
services:
|
||||
- issues
|
||||
# namespace создан руками и живёт вне terraform, запись нужна только чтобы
|
||||
# стек secrets знал, куда класть rabbitmq и regcred
|
||||
manage: false
|
||||
image_pull_secret: true
|
||||
7
environments/brusnika-stage/services/issues.yaml
Normal file
7
environments/brusnika-stage/services/issues.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
service: issues
|
||||
owner: platform
|
||||
|
||||
needs:
|
||||
rabbitmq:
|
||||
vhosts:
|
||||
- issues
|
||||
135
live/_shared/config.hcl
Normal file
135
live/_shared/config.hcl
Normal 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)
|
||||
}
|
||||
44
live/contracts/terragrunt.hcl
Normal file
44
live/contracts/terragrunt.hcl
Normal 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
|
||||
}
|
||||
@ -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", "")
|
||||
}
|
||||
|
||||
@ -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, [])
|
||||
|
||||
@ -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
65
modules/contracts/main.tf
Normal 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)}") : ""
|
||||
}
|
||||
14
modules/contracts/outputs.tf
Normal file
14
modules/contracts/outputs.tf
Normal 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))
|
||||
}
|
||||
23
modules/contracts/variables.tf
Normal file
23
modules/contracts/variables.tf
Normal 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 = {}
|
||||
}
|
||||
3
modules/contracts/versions.tf
Normal file
3
modules/contracts/versions.tf
Normal file
@ -0,0 +1,3 @@
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user