mirror of
https://gitlab.sarex.io/infra/terraform-contour-mirror.git
synced 2026-10-19 14:31: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], {})
|
env_config = try(local.infra_config.environments[local.env_name], {})
|
||||||
|
|
||||||
|
# компилятор контракта; для непереехавших окружений отдаёт пустые списки
|
||||||
|
shared = read_terragrunt_config("${local.repo_root}/live/_shared/config.hcl")
|
||||||
}
|
}
|
||||||
|
|
||||||
remote_state {
|
remote_state {
|
||||||
@ -44,7 +47,7 @@ terraform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
namespaces = try(local.env_config.namespaces, [])
|
namespaces = concat(try(local.env_config.namespaces, []), local.shared.locals.namespaces_input)
|
||||||
kubeconfig_path = get_env("KUBECONFIG", "")
|
kubeconfig_path = get_env("KUBECONFIG", "")
|
||||||
kube_context = get_env("KUBE_CONTEXT", "")
|
kube_context = get_env("KUBE_CONTEXT", "")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,9 @@ locals {
|
|||||||
# 127.0.0.1:15672 вместо брокера.
|
# 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_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"
|
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 {
|
remote_state {
|
||||||
@ -61,9 +64,9 @@ inputs = {
|
|||||||
amqp_host = try(local.env_config.rabbitmq.amqp_host, "rabbitmq.rabbitmq.svc.cluster.local")
|
amqp_host = try(local.env_config.rabbitmq.amqp_host, "rabbitmq.rabbitmq.svc.cluster.local")
|
||||||
amqp_port = try(local.env_config.rabbitmq.amqp_port, 5672)
|
amqp_port = try(local.env_config.rabbitmq.amqp_port, 5672)
|
||||||
|
|
||||||
vhosts = try(local.env_config.rabbitmq.vhosts, [])
|
vhosts = concat(try(local.env_config.rabbitmq.vhosts, []), local.shared.locals.rabbitmq_vhosts)
|
||||||
users = try(local.env_config.rabbitmq.users, [])
|
users = concat(try(local.env_config.rabbitmq.users, []), local.shared.locals.rabbitmq_users)
|
||||||
permissions = try(local.env_config.rabbitmq.permissions, [])
|
permissions = concat(try(local.env_config.rabbitmq.permissions, []), local.shared.locals.rabbitmq_permissions)
|
||||||
topic_permissions = try(local.env_config.rabbitmq.topic_permissions, [])
|
topic_permissions = try(local.env_config.rabbitmq.topic_permissions, [])
|
||||||
exchanges = try(local.env_config.rabbitmq.exchanges, [])
|
exchanges = try(local.env_config.rabbitmq.exchanges, [])
|
||||||
queues = try(local.env_config.rabbitmq.queues, [])
|
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)}") : ""
|
_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.
|
# 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.
|
# 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