Author SHA1 Message Date
Nail dbaadccc50 docs(resume): подробное резюме состояния Штурвал/freeze для нового чата + стенд DEV_STAND/FPipeGmail 2026-09-25 08:05:12 +03:00
Nail c808a3b345 docs: повышение читаемости provider-behavior.md (упрощены §1 списком, §2 модификаторы, §3 id/нормализация, §5 приоритет флагов, §6 ALB-константы) 2026-09-24 20:53:17 +03:00
Nail aaf87d966b docs: страница «Как работает провайдер: отличия от канонического Terraform» (freeze/destroy, пайплайн vDC→Edge→IP→SNAT→Штурвал, FAQ) + кейс UUID внутри JSON в case-sensitivity документе 2026-09-24 20:40:18 +03:00
Repinoid 10520670a5 1 2026-09-24 20:27:54 +03:00
Nail 208d97e2ce docs+release(dev): 2.0.23 — аудит регистра UUID (8 мест), фикс внутри JSON, залито в реестр 2026-09-24 20:22:39 +03:00
Nail 03fff05117 docs(gitignore/embed): operation_timeouts.json — исходник, а не артефакт; профильные значения подменяются при релизе 2026-09-24 20:22:11 +03:00
Nail 621280a530 fix(uuid-case): нормализация UUID внутри JSON — adopt suspended-инстанса больше не падает на регистре (jsonutil + JsonNormalize), тесты 2026-09-24 20:17:32 +03:00
Nail c9d73450b0 docs: проверен цикл destroy=заморозка на живом стенде (5 destroyed, кластер/vDC suspend, эдж/SNAT/квота не тронуты) 2026-09-24 19:44:26 +03:00
Nail 5fd64b68d0 gitignore: TMP/devbin и terraform-provider-nubes — локально собранные бинарники не в git 2026-09-24 19:24:49 +03:00
Nail 4bdf03a531 tmp: бэкапы файлов перед правками заморозки + terraformrc для dev_overrides 2026-09-24 19:23:39 +03:00
Nail eaff056d9c stand(FullPipe): провайдер переведён на 2.0.22 2026-09-24 19:23:39 +03:00
Nail 77de8cece6 docs: стенд FullPipe переведён на 2.0.22, plan без изменений, флаги заморозки зафиксированы в state 2026-09-24 19:22:37 +03:00
Nail cab606b90e docs: релиз dev-провайдера 2.0.22 зафиксирован (залит в реестр, VERSIONS.md обновлён) 2026-09-24 19:14:53 +03:00
Nail c29df2173f release(dev): 2.0.22 — keep_on_destroy (state_only) для всех instance-ресурсов + предупреждения в Delete 2026-09-24 19:14:39 +03:00
Nail ba3887fa69 docs: фиксирую реализацию freeze-on-destroy (генератор 22c6c83, конфиг стенда 40aef87) и порядок проверки через dev_overrides 2026-09-24 19:07:06 +03:00
Nail 40aef879e4 stand(FullPipe): режим «заморозки» на destroy — keep_on_destroy=true (эдж/SNAT/квота IP), adopt для эджа, явный suspend_on_destroy для кластера 2026-09-24 19:06:46 +03:00
Nail 22c6c83a0f generator: третий режим destroy keep_on_destroy (state_only) для всех instance-ресурсов + предупреждения «заморожен/оставлен как есть» 2026-09-24 19:06:40 +03:00
Nail 0de72e09f0 docs(history): запись за 2026-09-24 — adopt для кластера Штурвал, диагностика dev-00 и направление freeze-on-destroy 2026-09-24 19:00:12 +03:00
Nail 3df93ad07f docs(notes): диагностика Штурвал dev-00 (44/48 подов, мусор init-job) + разбор ошибки destroy по квоте IP и дизайн freeze-on-destroy через генератор 2026-09-24 18:59:53 +03:00
Nail 57abb7bfa4 stand(FullPipe): adopt_existing_on_create=true для кластера Штурвал — apply усыновляет существующий инстанс shturval-dev вместо ошибки «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)» 2026-09-24 18:18:57 +03:00
Nail 72d5771ffc stand(FullPipe): worker_configuration в camelCase (groupName/sizingPolicy/sizingDisk/labelDeck) — платформа падала на split() on null 2026-09-24 17:15:56 +03:00
Nail d98f6036d1 stand(FullPipe): добавлен Kubernetes кластер Штурвал — сервис 150 (nubes_k8s_sthutrval_cluster, vdc_uid+nsxt_uid), operation_timeout 60m 2026-09-24 16:51:24 +03:00
Nail 1a90c7737d stand(FullPipe): удалён Штурвал из конфига (был добавлен неверный сервис 148 вместо 150) 2026-09-24 16:45:18 +03:00
Nail b8adeb6582 stand(FullPipe): всё про Штурвал собрано в shturval.tf (переменные + ресурс); в variables.tf и terraform.tfvars ничего про Штурвал не осталось 2026-09-24 16:35:57 +03:00
Nail 5a2a5e7487 stand(FullPipe): добавлен Штурвал (nubes_vc_mgmt_sthutrval_cluster, 148) в конец цепочки vDC -> Edge -> IP -> SNAT 2026-09-24 16:27:57 +03:00
Nail 1611f7afa8 docs(curated): страницы примеров приведены к реальным файлам (versions/provider/variables/outputs), организация по имени, снята пометка «не проверено» 2026-09-24 16:15:15 +03:00
Repinoid 418b5645e5 docs: DEV 2.0.21 залит — ресурс аллокации принимает имя организации; стенд переведён на 2.0.21 2026-09-24 15:35:18 +03:00
Repinoid 5bd197f031 feat(provider): ресурс аллокации принимает имя организации (резолв в UUID через ResolveRefSvcParamValue, как в nubes_vc_vdc); конфиги и доки без org_uid 2026-09-24 15:28:13 +03:00
Repinoid bd5de0cead docs(pipeline): страница переписана как инструкция для пользователя (шаги, значения из ЛК, команды) 2026-09-24 15:21:41 +03:00
Repinoid c3b82cf074 docs(pipeline): ссылка на репозиторий примеров tf_examples и порядок клонирования 2026-09-24 15:00:07 +03:00
Repinoid 9590005914 docs: страница пайплайна vDC → Edge → внешние IP → SNAT (Ресурсы-модификаторы (IP организации, SNAT)) 2026-09-24 14:56:00 +03:00
Repinoid caa55d9ff8 chore(stand/FullPipe): провайдер 2.0.20 (проверено plan из реестра) 2026-09-24 14:16:05 +03:00
Repinoid d76418303a docs: DEV 2.0.20 залит (nubes-dev) — исправлен plan-modifier в nubes_vc_org_ip_allocation 2026-09-24 14:11:33 +03:00
Repinoid 6196a0119a docs(rules): добавить правило — при неясной команде переспросить и подтвердить, не гадать 2026-09-24 14:05:06 +03:00
Repinoid e25ef02a1a docs: убрать устаревшее «канонизация в plan-modifier» (совет Opus был неверен); план живого прогона FullPipe 2026-09-24 14:04:08 +03:00
Repinoid 807dfde287 fix(provider): убран plan-modifier, менявший пользовательское значение (Terraform: planned value must match config); сравнение аллокаций — смысловое в Read 2026-09-24 13:57:56 +03:00
Repinoid 721c3fcfab feat(stand/FullPipe): орг organ (org_uid) + ресурсы-модификаторы — аллокация IP после эджа, затем SNAT 2026-09-24 13:48:53 +03:00
Repinoid e6675be906 docs: DEV 2.0.19 залит (nubes-dev) — правки по ревью ресурсов-модификаторов 2026-09-24 10:57:10 +03:00
Repinoid ed4493c0ee docs: правки по ревью (канонизация, destroy-семантика) + статус выполнения 2026-09-24 10:52:51 +03:00
Repinoid 1236c59e18 test(provider): тесты канонизации vIPConfigure (jsonencode-форма, пробелы, [{}], невалидный JSON) 2026-09-24 10:52:36 +03:00
Repinoid 4b497e61db fix(provider): nsxt_snat — не писать null в Required-атрибут, ошибки API в Delete → error, валидация пустого ip_space_name 2026-09-24 10:52:36 +03:00
Repinoid ba6c4f5122 fix(provider): канонизирующий plan-modifier для vip_configure (jsonencode сортирует ключи → вечный diff); не писать null в Required; Delete: ошибки API → error 2026-09-24 10:52:36 +03:00
Repinoid 3374bf4e08 docs(prompts): ответ Opus на ревью кода ресурсов-модификаторов (блокеры: порядок ключей, Required+null) + список правок 2026-09-24 10:51:10 +03:00
Repinoid 648db99628 docs(prompts): промпт на ревью Opus — полный код двух ресурсов-модификаторов, известный баг и вопросы 2026-09-24 10:49:15 +03:00
Repinoid 9412106e3f docs: DEV 2.0.18 залит (nubes-dev) — ресурсы-модификаторы vc_org_ip_allocation и vc_nsxt_snat 2026-09-24 10:34:02 +03:00
Repinoid 6e6d223c22 docs(plans): §13 — статус работ (сделано/ждёт команды) 2026-09-24 10:26:36 +03:00
Repinoid 62abcd64f5 docs(providers): страница ресурсов-модификаторов (nubes_vc_org_ip_allocation, nubes_vc_nsxt_snat) + nav 2026-09-24 10:26:21 +03:00
Repinoid 3973f912fc chore(docs): удалить docs/TODO/what_not_in_terraform.md + убрать ссылки на него из комментариев 2026-09-24 10:24:13 +03:00
Repinoid 73a7459a38 feat(provider): регистрация ресурсов-модификаторов vc_org_ip_allocation и vc_nsxt_snat 2026-09-24 10:22:17 +03:00
Repinoid 80d82a145a feat(provider): ресурс nubes_vc_nsxt_snat (modify ipSpaceName, inverse no-needed) 2026-09-24 10:22:17 +03:00
Repinoid 22cf2595ee feat(provider): ресурс nubes_vc_org_ip_allocation (modify vIPConfigure, uid орги) + тесты нормализации 2026-09-24 10:22:17 +03:00
Repinoid 574e300476 docs(plans): §12 — орга делается руками в ЛК, в tf только uid; правка генератора не блокер, нужны только 2 ресурса 2026-09-24 10:10:55 +03:00
Repinoid cb8389c17f docs(plans): §11 — ответы Opus раунд 3 (критерий отбора = явный список в конфиге генератора, релиз A только (б), Deprecated вместо падения) 2026-09-24 10:04:25 +03:00
Repinoid 664f04eb49 docs(plans): §10 — вопрос Опусу про безопасность универсальной правки графа генератора (5 сервисов с modify-only) 2026-09-24 10:00:57 +03:00
Repinoid 602b27ee1a docs(plans): ревью Opus по плану — §9 (ответы на 5 вопросов, count строкой, обязательный follow-up по генератору) 2026-09-24 09:55:02 +03:00
Repinoid 97d5ca818e docs(plans): план двух ресурсов-модификаторов (nubes_vc_org_ip_allocation, nubes_vc_nsxt_snat) + вопросы на ревью 2026-09-24 09:32:55 +03:00
Repinoid bccf8f7320 docs(notes): раунд 2 Q&A с Opus (владелец параметра, массив vs элемент, keep_on_destroy, deprecated-переход, тип атрибута) 2026-09-24 09:31:10 +03:00
Repinoid 129dab97a0 docs(notes): Q&A с Opus по дизайну ресурсов-модификаторов + замечания к ответам 2026-09-24 09:29:22 +03:00
Repinoid 75700a92da docs(notes): исправлен ложный факт «схема только из create» в CHAT_RESUME_IAC (loader.go:96 мержит create+modify) 2026-09-24 08:48:54 +03:00
Repinoid 51ff9b3751 docs(notes): разбор fresh-create HAR — state после create (vIPConfigure=[{}], ipSpaceName только в modify) 2026-09-24 08:48:54 +03:00
54 changed files with 5839 additions and 75 deletions
+2 -1
View File
@@ -13,4 +13,5 @@
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений. коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ. ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ. НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ. ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
+6
View File
@@ -6,6 +6,9 @@
.terraform.lock.hcl .terraform.lock.hcl
# === Generated files (NOT code — regenerate from API) === # === Generated files (NOT code — regenerate from API) ===
# ВАЖНО: provider/internal/provider/operation_timeouts.json — НЕ артефакт.
# Это дефолтный конфиг таймаутов для go build/go test (см. operation_timeouts_embed.go),
# поэтому он намеренно отслеживается git. Профильные значения — в TOOLS/config/<profile>/.
provider/resources_yaml/ provider/resources_yaml/
provider/internal/resources_gen/ provider/internal/resources_gen/
@@ -30,6 +33,9 @@ provider/generated/
*.exe *.exe
*.test *.test
*.out *.out
# Локально собранный провайдер под dev_overrides (см. TMP/terraformrc.dev)
TMP/devbin/
terraform-provider-nubes
# === Build artifacts (generated by devops scripts) === # === Build artifacts (generated by devops scripts) ===
devops/profiles/*/generated/ devops/profiles/*/generated/
+28
View File
@@ -0,0 +1,28 @@
resource "nubes_vc_nsxt" "edge" {
resource_name = var.nsxt_resource_name
# Тип родительской услуги: "vdc" (нужен vdc_uid) или "vdcGroup" (нужен vdc_group_uid)
vdc_type = var.nsxt_vdc_type
# refSvc-поле: принимает UUID или имя. Здесь берём UID созданного VDC,
# чтобы Edge гарантированно создавался после vDC.
vdc_uid = nubes_vc_vdc.vdc.id
need_enable_avi = var.nsxt_need_enable_avi
virtual_services_count = var.nsxt_virtual_services_count
# routed-сеть, которую разворачивает Edge (SingleNestedAttribute -> объект)
routed_net_configuration = {
ip_addr_pool = var.nsxt_ip_addr_pool
main_dns = var.nsxt_main_dns
second_dns = var.nsxt_second_dns
}
# «Заморозка»: destroy НЕ удаляет эдж (у платформы для эджа нет операции suspend),
# а только убирает его из состояния. Для полного удаления — keep_on_destroy = false.
keep_on_destroy = true
# Повторный apply усыновляет уже работающий эдж, а не падает с
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)».
adopt_existing_on_create = true
}
+60
View File
@@ -0,0 +1,60 @@
# =============================================================================
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
#
# Порядок строго такой:
# орга (создана вручную в ЛК)
# -> nubes_vc_vdc.vdc
# -> nubes_vc_nsxt.edge
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
#
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
# созданный vDC и Edge. Иначе modify на орге падает
# («Can't cast Complex Object Type Struct to String»).
# =============================================================================
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" {
organization = var.organization
vip_configure = jsonencode([
{
name = var.ip_space_name
count = var.ip_count
}
])
# true = «заморозка»: destroy не трогает квоту внешних IP (кластер Штурвала держит
# адреса, опустить count ниже занятых платформа не даёт). Для полного удаления — false
# (и только после удаления кластера).
keep_on_destroy = true
depends_on = [nubes_vc_nsxt.edge]
}
# 2. SNAT на эдже (modify: ipSpaceName)
resource "nubes_vc_nsxt_snat" "snat" {
nsxt_uid = nubes_vc_nsxt.edge.id
ip_space_name = var.ip_space_name
# true = «заморозка»: destroy не выключает SNAT на эдже. Для полного удаления — false.
keep_on_destroy = true
# ipSpace должен быть уже выделен на организации
depends_on = [nubes_vc_org_ip_allocation.org_ip]
}
output "allocated_org_ip" {
description = "Выделено внешних IP на организации"
value = {
organization = var.organization
ip_space_name = var.ip_space_name
ip_count = var.ip_count
}
}
output "snat_ip_space" {
description = "ipSpace, включённый как SNAT на эдже"
value = nubes_vc_nsxt_snat.snat.ip_space_name
}
+29
View File
@@ -0,0 +1,29 @@
output "vdc_id" {
description = "UID созданного VDC"
value = nubes_vc_vdc.vdc.id
}
output "vdc_name" {
description = "Имя VDC"
value = nubes_vc_vdc.vdc.resource_name
}
output "vdc_state_params" {
description = "Параметры состояния VDC из API"
value = nubes_vc_vdc.vdc.state_params
}
output "nsxt_id" {
description = "UID созданного Edge (vc_nsxt)"
value = nubes_vc_nsxt.edge.id
}
output "nsxt_name" {
description = "Имя Edge (vc_nsxt)"
value = nubes_vc_nsxt.edge.resource_name
}
output "nsxt_state_params" {
description = "Параметры состояния Edge (vc_nsxt) из API"
value = nubes_vc_nsxt.edge.state_params
}
+4
View File
@@ -0,0 +1,4 @@
provider "nubes" {
api_token = var.api_token
api_endpoint = var.api_endpoint
}
+161
View File
@@ -0,0 +1,161 @@
# =============================================================================
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
#
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
# или закомментировать ресурс.
#
# Порядок (чек-лист из инструкции на услугу в ЛК):
# 1) Организация в Cloud Director — создана вручную в ЛК
# 2) nubes_vc_vdc.vdc — есть
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
# 5) SNAT на Edge — nubes_vc_nsxt_snat
# 6) Kubernetes кластер Штурвал — этот ресурс
#
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
# =============================================================================
# --- Переменные Штурвала ---
variable "shturval_resource_name" {
type = string
default = "shturval-dev"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-00"
description = "Имя кластера внутри Штурвала"
}
variable "shturval_app_version" {
type = string
default = "2.14.0"
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
}
variable "shturval_cp_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
}
variable "shturval_cp_sizing_disk" {
type = number
default = 50
description = "Диск control plane, ГБ (минимум 50)"
}
variable "shturval_cp_count" {
type = number
default = 1
description = "Количество мастер-нод: 1, 3 или 5"
}
variable "shturval_worker_group_name" {
type = string
default = "workers-shturval-dev"
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
}
variable "shturval_worker_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
}
variable "shturval_worker_sizing_disk" {
type = number
default = 50
description = "Диск воркеров, ГБ (минимум 50)"
}
variable "shturval_worker_count" {
type = number
default = 1
description = "Количество воркер-нод (минимум 1)"
}
# --- Значения, которые собираются из переменных ---
locals {
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
# sizingDisk, count, autoscale, labelDeck.
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
# в snake_case — это ошибка генератора, платформа на них падает с
# «Cannot invoke method split() on null object» (не находит groupName → null).
shturval_worker_config = jsonencode([
{
groupName = var.shturval_worker_group_name
sizingPolicy = var.shturval_worker_sizing_policy
sizingDisk = var.shturval_worker_sizing_disk
count = var.shturval_worker_count
autoscale = false # автоскейл выключен
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
}
])
}
# --- Ресурс Штурвала ---
resource "nubes_k8s_sthutrval_cluster" "shturval" {
resource_name = var.shturval_resource_name
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
adopt_existing_on_create = true
# «Заморозка»: destroy приостанавливает кластер (suspend), а не удаляет.
# Следующий apply усыновит его и разморозит (resume).
suspend_on_destroy = true
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
# иначе провайдер сдаётся на дефолтных 600 с.
operation_timeout = "60m"
startup_configuration = {
# vDC и Edge из этого же конфига (обязательные поля)
vdc_uid = nubes_vc_vdc.vdc.id
nsxt_uid = nubes_vc_nsxt.edge.id
cluster_name = var.shturval_cluster_name
# Дополнительные возможности кластера (в ЛК — галочки при создании)
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
ex_local_csi = true
ex_vip = true
ex_update = true
ex_ingress = true
ex_named_csi = true
}
cluster_configuration = {
app_version = var.shturval_app_version
}
control_plane_configuration = {
sizing_policy = var.shturval_cp_sizing_policy
sizing_disk = var.shturval_cp_sizing_disk
count = var.shturval_cp_count
}
worker_configuration = local.shturval_worker_config
access_configuration = {
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
access_ip_list_api = jsonencode([]) # пусто = доступ всем
need_external_address_ingress = true # внешний адрес для Ingress
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
}
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
depends_on = [nubes_vc_nsxt_snat.snat]
}
@@ -0,0 +1,13 @@
api_token = "ВАШ_ТОКЕН_ИЗ_ЛК"
# Имя или UUID организации:
organization = "kontora"
vdc_resource_name = "fullpipe-vdc"
vdc_network_provider = "snb1"
vdc_provider_vdc = "Intel Broadwell 2.4"
vdc_cpu_allocated = 8
vdc_cpu_guaranteed = 0
vdc_mem_allocated = 32
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
+116
View File
@@ -0,0 +1,116 @@
variable "api_token" {
type = string
sensitive = true
description = "API-токен Nubes"
}
variable "api_endpoint" {
type = string
default = "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc"
description = "API Gateway URL"
}
# Имя (display_name, напр. "kontora") ИЛИ UUID организации из ЛК
variable "organization" {
type = string
description = "Имя или UUID организации (vc_org)"
}
# --- Модификаторы (IP на орге + SNAT на эдже) ---
variable "ip_space_name" {
type = string
description = "Имя ipSpace, доступное организации (смотреть в ЛК, напр. internet-ipv4-v1)"
}
variable "ip_count" {
type = string
default = "3"
description = "Сколько внешних IP выделить на организации (count — строка)"
}
variable "vdc_resource_name" {
type = string
default = "fullpipe-vdc"
description = "Имя VDC"
}
variable "vdc_network_provider" {
type = string
default = null
description = "Сетевой провайдер. Заполнить значением из текущей страницы ЛК"
}
variable "vdc_provider_vdc" {
type = string
default = null
description = "Provider VDC. Заполнить значением из текущей страницы ЛК"
}
variable "vdc_cpu_allocated" {
type = number
default = 8
description = "vCPU (шт.)"
}
variable "vdc_cpu_guaranteed" {
type = number
default = 0
description = "Резервирование vCPU (%, допустимо: 0, 50, 80)"
}
variable "vdc_mem_allocated" {
type = number
default = 32
description = "RAM (GB)"
}
variable "vdc_storage_config" {
type = string
default = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
description = "Дисковое хранилище (JSON-массив, size в GB). Имя политики должно существовать в ресурсном пуле (например, SATA, SSD)"
}
# --- vc_nsxt (Сетевой шлюз периметра / Edge) ---
variable "nsxt_resource_name" {
type = string
default = "fullpipe-edge"
description = "Имя Edge (vc_nsxt)"
}
variable "nsxt_vdc_type" {
type = string
default = "vdc"
description = "Тип родительской услуги: vdc или vdcGroup"
}
variable "nsxt_need_enable_avi" {
type = bool
default = true
description = "Включить AVI Load Balancer (ALB)"
}
variable "nsxt_virtual_services_count" {
type = number
default = 3
description = "Кол-во виртуальных сервисов на AVI (1..4; Штурвал: ≥ 3)"
}
variable "nsxt_ip_addr_pool" {
type = string
default = "10.10.102.0/24"
description = "Адресный пул routed-сети (маска /24 обязательна)"
}
variable "nsxt_main_dns" {
type = string
default = "81.22.46.22"
description = "Основной DNS"
}
variable "nsxt_second_dns" {
type = string
default = "185.247.187.77"
description = "Второй DNS"
}
+20
View File
@@ -0,0 +1,20 @@
resource "nubes_vc_vdc" "vdc" {
resource_name = var.vdc_resource_name
# Организация: имя из ЛК ("kontora") или точный UUID
organization_uid = var.organization
network_provider = var.vdc_network_provider
provider_vdc = var.vdc_provider_vdc
cpu_allocated = var.vdc_cpu_allocated
cpu_guaranteed = var.vdc_cpu_guaranteed
mem_allocated = var.vdc_mem_allocated
# JSON-массив дисковых политик (size в GB)
storage_config = var.vdc_storage_config
suspend_on_destroy = true
adopt_existing_on_create = true
}
+10
View File
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "2.0.23"
}
}
}
+8
View File
@@ -17,4 +17,12 @@ resource "nubes_vc_nsxt" "edge" {
main_dns = var.nsxt_main_dns main_dns = var.nsxt_main_dns
second_dns = var.nsxt_second_dns second_dns = var.nsxt_second_dns
} }
# «Заморозка»: destroy НЕ удаляет эдж (у платформы для эджа нет операции suspend),
# а только убирает его из состояния. Для полного удаления — keep_on_destroy = false.
keep_on_destroy = true
# Повторный apply усыновляет уже работающий эдж, а не падает с
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)».
adopt_existing_on_create = true
} }
+60
View File
@@ -0,0 +1,60 @@
# =============================================================================
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
#
# Порядок строго такой:
# орга (создана вручную в ЛК)
# -> nubes_vc_vdc.vdc
# -> nubes_vc_nsxt.edge
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
#
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
# созданный vDC и Edge. Иначе modify на орге падает
# («Can't cast Complex Object Type Struct to String»).
# =============================================================================
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" {
organization = var.organization
vip_configure = jsonencode([
{
name = var.ip_space_name
count = var.ip_count
}
])
# true = «заморозка»: destroy не трогает квоту внешних IP (кластер Штурвала держит
# адреса, опустить count ниже занятых платформа не даёт). Для полного удаления — false
# (и только после удаления кластера).
keep_on_destroy = true
depends_on = [nubes_vc_nsxt.edge]
}
# 2. SNAT на эдже (modify: ipSpaceName)
resource "nubes_vc_nsxt_snat" "snat" {
nsxt_uid = nubes_vc_nsxt.edge.id
ip_space_name = var.ip_space_name
# true = «заморозка»: destroy не выключает SNAT на эдже. Для полного удаления — false.
keep_on_destroy = true
# ipSpace должен быть уже выделен на организации
depends_on = [nubes_vc_org_ip_allocation.org_ip]
}
output "allocated_org_ip" {
description = "Выделено внешних IP на организации"
value = {
organization = var.organization
ip_space_name = var.ip_space_name
ip_count = var.ip_count
}
}
output "snat_ip_space" {
description = "ipSpace, включённый как SNAT на эдже"
value = nubes_vc_nsxt_snat.snat.ip_space_name
}
+161
View File
@@ -0,0 +1,161 @@
# =============================================================================
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
#
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
# или закомментировать ресурс.
#
# Порядок (чек-лист из инструкции на услугу в ЛК):
# 1) Организация в Cloud Director — создана вручную в ЛК
# 2) nubes_vc_vdc.vdc — есть
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
# 5) SNAT на Edge — nubes_vc_nsxt_snat
# 6) Kubernetes кластер Штурвал — этот ресурс
#
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
# =============================================================================
# --- Переменные Штурвала ---
variable "shturval_resource_name" {
type = string
default = "shturval-dev"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-00"
description = "Имя кластера внутри Штурвала"
}
variable "shturval_app_version" {
type = string
default = "2.14.0"
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
}
variable "shturval_cp_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
}
variable "shturval_cp_sizing_disk" {
type = number
default = 50
description = "Диск control plane, ГБ (минимум 50)"
}
variable "shturval_cp_count" {
type = number
default = 1
description = "Количество мастер-нод: 1, 3 или 5"
}
variable "shturval_worker_group_name" {
type = string
default = "workers-shturval-dev"
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
}
variable "shturval_worker_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
}
variable "shturval_worker_sizing_disk" {
type = number
default = 50
description = "Диск воркеров, ГБ (минимум 50)"
}
variable "shturval_worker_count" {
type = number
default = 1
description = "Количество воркер-нод (минимум 1)"
}
# --- Значения, которые собираются из переменных ---
locals {
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
# sizingDisk, count, autoscale, labelDeck.
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
# в snake_case — это ошибка генератора, платформа на них падает с
# «Cannot invoke method split() on null object» (не находит groupName → null).
shturval_worker_config = jsonencode([
{
groupName = var.shturval_worker_group_name
sizingPolicy = var.shturval_worker_sizing_policy
sizingDisk = var.shturval_worker_sizing_disk
count = var.shturval_worker_count
autoscale = false # автоскейл выключен
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
}
])
}
# --- Ресурс Штурвала ---
resource "nubes_k8s_sthutrval_cluster" "shturval" {
resource_name = var.shturval_resource_name
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
adopt_existing_on_create = true
# «Заморозка»: destroy приостанавливает кластер (suspend), а не удаляет.
# Следующий apply усыновит его и разморозит (resume).
suspend_on_destroy = true
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
# иначе провайдер сдаётся на дефолтных 600 с.
operation_timeout = "60m"
startup_configuration = {
# vDC и Edge из этого же конфига (обязательные поля)
vdc_uid = nubes_vc_vdc.vdc.id
nsxt_uid = nubes_vc_nsxt.edge.id
cluster_name = var.shturval_cluster_name
# Дополнительные возможности кластера (в ЛК — галочки при создании)
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
ex_local_csi = true
ex_vip = true
ex_update = true
ex_ingress = true
ex_named_csi = true
}
cluster_configuration = {
app_version = var.shturval_app_version
}
control_plane_configuration = {
sizing_policy = var.shturval_cp_sizing_policy
sizing_disk = var.shturval_cp_sizing_disk
count = var.shturval_cp_count
}
worker_configuration = local.shturval_worker_config
access_configuration = {
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
access_ip_list_api = jsonencode([]) # пусто = доступ всем
need_external_address_ingress = true # внешний адрес для Ingress
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
}
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
depends_on = [nubes_vc_nsxt_snat.snat]
}
+13
View File
@@ -16,6 +16,19 @@ variable "organization" {
description = "Имя или UUID организации (vc_org)" description = "Имя или UUID организации (vc_org)"
} }
# --- Модификаторы (IP на орге + SNAT на эдже) ---
variable "ip_space_name" {
type = string
description = "Имя ipSpace, доступное организации (смотреть в ЛК, напр. internet-ipv4-v1)"
}
variable "ip_count" {
type = string
default = "3"
description = "Сколько внешних IP выделить на организации (count — строка)"
}
variable "vdc_resource_name" { variable "vdc_resource_name" {
type = string type = string
default = "fullpipe-vdc" default = "fullpipe-vdc"
+1 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers { required_providers {
nubes = { nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes" source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "2.0.17" version = "2.0.23"
} }
} }
} }
@@ -0,0 +1,62 @@
# 2026-09-24 — Штурвал dev-00: диагностика, adopt и дизайн «freeze on destroy»
Краткая запись по дню. Разбор с источниками (файл:строка, ответы API, логи) —
`NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md`,
резюме для продолжения — `NOTES/40_chat_summaries/CHAT_RESUME_2026-09-24_shturval_freeze.md`.
## Изменения в репозитории
| Что | Файл | Коммит |
|---|---|---|
| `adopt_existing_on_create = true` для кластера Штурвала (иначе apply падал на существующем suspended-инстансе) | `DEV_STAND/FullPipe/shturval.tf` | `57abb7b` |
| Документация сессии (диагностика + дизайн freeze) | `NOTES/30_analysis/…`, `NOTES/40_chat_summaries/…` | `3df93ad` |
| Универсальный третий режим destroy `keep_on_destroy` (`state_only`) для всех instance-ресурсов + предупреждения в `Delete` | `TOOLS/resource-generator/{types.go,loader.go,templates/instance.go}` | `22c6c83` |
| Режим «заморозки» в конфиге стенда: `keep_on_destroy=true` (эдж/SNAT/квота IP), adopt для эджа, явный `suspend_on_destroy` у кластера | `DEV_STAND/FullPipe/{edge.tf,modifiers.tf,shturval.tf}` | `40aef87` |
| Релиз dev-провайдера `2.0.22` (три платформы + SHA256SUMS/подпись, залито в реестр) | `VERSIONS.md` | `c29df21` |
## Баг после заморозки: регистр UUID внутри JSON (исправлен)
- Первый `apply` после freeze упал: `required params mismatch … startupConfiguration` — `nsxtUid` в плане
(`2c37fed1-…`, lowercase из пересозданного эджа) против `2C37FED1-…` (UPPERCASE) в живом инстансе.
- Причина: регистр UUID нормализовался в 5 местах (отправка в API, одиночные значения, create-only сравнение,
state), но **внутри JSON** — нет; adopt приостановленного инстанса сравнивает параметр целиком как JSON.
- Проведён аудит (8 мест, таблица в `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md` §5.3).
- Фикс: `jsonutil.LowercaseUUIDsInText` + нормализация строк внутри JSON (закрывает adopt-suspended, modifier-compare,
state_refresh, диагностику), UUID-подстроки в `JsonNormalize()`; тесты в `jsonutil` и `resources_core`.
- Открыто: ref-параметр внутри JSON не валидируется при adopt; регистр ключей в `lookupLiveParam`.
## Проверка цикла на живом стенде
- `terraform destroy` (провайдер `2.0.22`): `0 added, 0 changed, 5 destroyed`, ошибок нет.
Кластер и vDC ушли в `suspend`, эдж остался `running` с `ipSpaceName=internet-ipv4-v1`, квота IP — `count=3`,
state пуст. Предупреждения: «заморожен, а не удалён» ×2 (кластер, vDC), «оставлен как есть» (эдж),
«Аллокация IP не снималась» (квота), «SNAT не выключался».
- Обратный ход (`apply` → adopt + `resume`) — следующий шаг, запускает пользователь.
Бэкап перед правкой: `TMP/backup_2026-09-24/shturval.tf.before-adopt`.
## Итоги диагностики кластера `shturval-dev-00`
- Кластер здоров: 2 ноды Ready (k8s v1.35.1, платформа 2.14.0), `shturvalserviceconfigs` 41/41 `ready`,
`nodeconfigitems` 4/4, endpoints есть у всех 35 сервисов.
- Единственный «мусор» — 4 подвисших пода `kube-system/shturval-init-job` (3 Error + 1 Unknown) при
`Complete 1/1` у Job. Причина: webhook-и Штурвала недоступны, пока Cilium не поднял сеть
(`connect: operation not permitted`). Самоочистка по `ttlSecondsAfterFinished: 86400` (~25.09 14:31 UTC).
- Счётчики ЛК расшифрованы: `Pods` = готовые/всего (без Completed), «Системные сервисы» = число сервисов в режиме
`auto` (17/24 во время установки → 24/24), «Ingress» — домен-шаблон, «Конфигурация узлов» — NodeConfigItems.
## Итоги разбора destroy
- `nubes_vc_org_ip_allocation` при `keep_on_destroy = false` отправляет `count=0` и падает, если квота занята
(2 адреса держит кластер: `.146` API, `.148` ingress; `suspend` их не освобождает).
- Упавший destroy оставляет «рваное» состояние: SNAT снят, edge/vDC/квота — нет.
- `adopt_existing_on_create = true` решает восстановление: apply усыновил инстанс `94627ff4-…` и сам сделал
`resume`; SNAT восстановлен (`internet-ipv4-v1`). Проверено на живом стенде.
## Принятое направление (дизайн)
Три режима destroy в одной общей логике: `delete` (дефолт), `suspend` (где сервис умеет),
`keep` → `state_only` (эдж, SNAT, квота IP). Реализация — через генератор
(`TOOLS/resource-generator`), без ручных правок `resources_gen/`. Дефолты провайдера остаются разрушающими,
freeze включается явно в `.tf` стенда; в `Delete` обязательны предупреждения («заморожено», «оставлено как есть»).
Полный teardown — только явный opt-out и в порядке: кластер → `count=0` → SNAT → эдж → vDC.
@@ -0,0 +1,78 @@
# ПЛАН: живой прогон цепочки на DEV_STAND/FullPipe (2026-09-24)
> Стенд: dev, орга **`organ`** (`57eeacd1-dc7f-4a52-b903-7e5f7d3c1164`, realm `sandbox.nubes.ru`, тип `saas`,
> CD-имя `WZ01325-saas`). Провайдер `2.0.19` (`terraform init -upgrade` уже сделан, `validate` — Success).
> **`apply`/`destroy` запускает только пользователь.**
## 0. Что уже готово
- Ресурсы `nubes_vc_org_ip_allocation` (modify `vIPConfigure`) и `nubes_vc_nsxt_snat` (modify `ipSpaceName`) —
в провайдере, собраны в `2.0.19`, залиты в `nubes-dev`, есть unit-тесты канонизации.
- Конфиг стенда: `DEV_STAND/FullPipe/` — `vdc.tf`, `edge.tf`, `modifiers.tf` (аллокация после эджа, затем SNAT),
`organization = "organ"` + `org_uid`.
- Орга создана вручную (в tf её нет) — по решению пользователя.
## 1. Цель прогона
Проверить **одним `apply`**: `vdc → edge → IP на орге → SNAT`, затем чистый повторный `plan` и корректный
`destroy`. Это первый живой прогон обоих новых ресурсов: CRUD до сих пор не проверялся.
## 2. Перед прогоном (проверить значения)
1. `vdc_network_provider` (`snb1`), `vdc_provider_vdc` (`Intel Broadwell 2.4`), `vdc_storage_config` (`SATA`) —
убедиться в ЛК, что доступны для орги `organ` (значения брались из ЛК для прежней орги).
2. `ip_space_name` — сначала может быть недоступен: **список ipSpace в ЛК падает** (`Can't cast Complex Object
Type Struct to String`), пока нет vDC/эджа. Брать имя из прежних HAR: `internet-ipv4-v1`.
3. `ip_count` — `"3"` (строка).
## 3. Шаги прогона (пользователь)
| # | Команда | Ожидаемый результат |
|---|---|---|
| 1 | `terraform plan` | создание: `nubes_vc_vdc.vdc` → `nubes_vc_nsxt.edge` → `nubes_vc_org_ip_allocation.org_ip` → `nubes_vc_nsxt_snat.snat`; порядка не меньше |
| 2 | `terraform apply` | всё создаётся за один проход |
| 3 | `terraform plan` (повторно) | **пустой** — главный тест канонизации (иначе вечный diff) |
| 4 | проверить API (см. §4) | `vIPConfigure` и `ipSpaceName` в live-состоянии |
| 5 | изменить `ip_count` 3 → 2, `plan`+`apply` | меняется только аллокация, state сходится |
| 6 | `terraform destroy` | порядок `snat (no-needed)` → `org_ip (count=0)` → `edge` → `vdc`; орги не касается |
## 4. Что проверять и чем
```bash
TOK=$(tr -d '\n' < secrets/narodDEV.token) # токен орги organ
# состояние орги
curl -s -H "Authorization: Bearer $TOK" 'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/<org_uid>'
# состояние эджа
curl -s -H "Authorization: Bearer $TOK" 'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/<nsxt_uid>'
```
**Гипотезы, которые прогон подтверждает/опровергает:**
1. **Имена live-ключей**: `state.params.vIPConfigure` (орга) и `state.params.ipSpaceName` (эдж) — взяты из HAR,
кодом не проверены. Если Read вернёт не то → увидим дрейф/пустое значение.
2. **Частичный payload не затирает остальное**: SNAT-модификация шлёт только `372`; `needEnableAVI`
и `virtualServicesCount` должны остаться прежними (`true` / `1`), т.к. досылаются из live
(`core/operation_run_bycode.go`). Проверить в состоянии эджа до/после.
3. **Один `apply`** проходит целиком без второго прогона (ради этого и делались ресурсы).
4. **Нет вечного diff** после apply (канонизация `vip_configure`).
5. **`Required` + пустое live** не даёт ошибок (лечение из ревью).
## 5. Точки отказа и что делать
| Симптом | Вероятная причина | Действие |
|---|---|---|
| аллокация падает `Can't cast ... Struct to String` | платформа ещё не видит `job.vcd.networkProvider`/`providerGateway` (эдж/VDC не в состоянии) | проверить порядок и фактическое состояние эджа; при необходимости — пауза/повторный `apply` |
| `Provider produced inconsistent result after apply` на `vdc`/`edge` | read-back перекрыл план (известный класс дефектов) | записать в NOTES, разбирать отдельно (это уже не про наши ресурсы) |
| повторный `plan` не пустой | порядок ключей/формат не сошлись | сверить, что вернул live, с `formatVipConfigure` |
| SNAT не включился | `372` не доехал / неверное имя ipSpace | проверить `state.params.ipSpaceName` эджа и лог операции |
| `destroy` падает | обратный modify на живой/мёртвый родитель | смотреть тексты диагностик ресурсов (мы развели: ошибка API ≠ «родителя нет») |
## 6. После прогона
1. Отчёт в `NOTES/30_analysis/` — что прошло, что упало, с HAR/логами.
2. Обновить память репозитория (подтверждённые факты вместо гипотез).
3. Если найдутся баги — отдельные коммиты + при необходимости новый релиз провайдера.
4. Публикация документации (`04_build_and_publish_docs.sh`) — отдельной командой.
**Не входит в этот прогон:** кластер Штурвал (`nubes_k8s_shturval_cluster`) — отдельным шагом, после того как
SNAT подтверждён.
@@ -0,0 +1,227 @@
# ПЛАН: два ресурса-модификатора для цепочки Штурвала (2026-09-24)
> Статус: **план, не реализовано**. Отправляется на ревью Opus.
> Решения приняты пользователем: 2 ресурса сейчас, универсальность потом; орга — не наша (адресация по uid);
> «один ресурс = весь массив `vIPConfigure`»; тип атрибута — String+JSON; apply — только пользователь.
## 1. Цель
Дать клиенту возможность собрать цепочку **одним `apply`**:
```
nubes_vc_org (вне state, адресация по uid)
nubes_vc_vdc → nubes_vc_nsxt
nubes_vc_org_ip_allocation (modify 662, vIPConfigure) ← новый ресурс
nubes_vc_nsxt_snat (modify 372, ipSpaceName) ← новый ресурс
nubes_k8s_shturval_cluster
```
Сейчас это невозможно: `Create` не отправляет modify-only параметры, а `Update` — второй прогон.
## 2. Вне scope
- Универсальный механизм (реестр модификаторов, генераторные метки) — потом.
- `vcExternalIp` — не разбирали.
- Правка генератора по modify-only (см. §7) — отдельный этап, требует решения.
## 3. Ресурс 1 — `nubes_vc_org_ip_allocation`
| | |
|---|---|
| Файл | `provider/internal/resources_core/org_ip_allocation_resource.go` (новый, hand-written) |
| Регистрация | `provider/internal/provider/provider.go`, `Resources()` (рядом с `NewServiceOperationResource`) |
| Атрибуты | `org_uid` — String, Required; `vip_configure` — String (JSON `[{"name":..,"count":..}]`), Required, нормализация JSON как в `resources_core/json_planmodifier.go`; `keep_on_destroy` — Bool, Optional, default `false` |
| ID | `org_uid` (один ресурс на оргу; массив целиком) |
| Create/Update | `modify` на инстансе орги: `vIPConfigure` = JSON-массив целиком (replace-семантика). Путь: `core.RunInstanceOperationUniversalByCode` (или обёртка `resources_core`), под `LockInstance(org_uid)` |
| Read | `core.GetInstanceStateParams(org_uid)` → ключ `vIPConfigure`; пустое/`[{}]`/`count=0` → нормализовать; родитель 404/deleted → `RemoveResource` (`resources_core.ShouldRemoveFromState`). **Нужен нормализующий planmodifier** (аналог JSON-модификатора), иначе вечный дрейф при плановом 3→0 (ревью Opus, п.3) |
| Delete | `keep_on_destroy=true` → no-op + Warning. Иначе: родитель жив → modify с `count="0"` по каждому элементу (**строкой**, как в HAR; форма проверена тестом 09-22) + Warning; родитель мёртв → no-op + Warning. Массив `[]` НЕ отправлять — не проверен (ревью Opus, п.2) |
| Import | passthrough по `org_uid` |
## 4. Ресурс 2 — `nubes_vc_nsxt_snat`
| | |
|---|---|
| Файл | `provider/internal/resources_core/nsxt_snat_resource.go` (новый) |
| Атрибуты | `nsxt_uid` — String, Required; `ip_space_name` — String, Required (`no-needed` = SNAT выключен, канон из HAR); `keep_on_destroy` — Bool, Optional, default `false` |
| ID | `nsxt_uid` |
| Create/Update | `modify` 372 = `ip_space_name`. Отправляется **только** 372 (остальные досыпаются из live — проверить, см. §8 вопрос 1) |
| Read | live `ipSpaceName` из `state.params`; отсутствует или `no-needed` → null; родитель мёртв → `RemoveResource` |
| Delete | inverse: `modify` с `ipSpaceName = "no-needed"` (канон, подтверждён HAR) |
| Import | passthrough по `nsxt_uid` |
## 5. Зависимости и порядок
```
nubes_vc_nsxt → nubes_vc_org_ip_allocation → nubes_vc_nsxt_snat → k8s cluster
```
- SNAT обязан зависеть от org-IP: имя ipSpace берётся из аллокации (ребра графа TF не видит — связь по имени).
- Destroy пойдёт обратно: cluster → SNAT (`no-needed`) → org-IP (`count=0`) → nsxt → vdc.
- Инвариант: destroy модификаторов **не трогает** саму оргу.
## 6. Этапы работ (последовательность)
1. **Проверка по коду** (чтение): приоритет live→paramValue→default при дозаполнении параметров; `instance.go:478` (что именно эмитит Update).
2. `nubes_vc_org_ip_allocation` + регистрация + unit-тесты (нормализация JSON, чтение `[{}]`, Delete-ветки).
3. `nubes_vc_nsxt_snat` + регистрация + unit-тесты.
4. Общие хелперы в `resources_core` (если дублируются).
5. Живой прогон на dev (**apply — пользователь**): `FullPipe`, орга **saas** (`organization_type = "saas"`, иначе коллизия имени `WZ03709-iaas`).
6. Проверки после прогона: `plan` чистый (нет дрейфа), SNAT включён в одном apply, `destroy` не падает.
7. Документация: `HOW_TO/`/`docs/`, `VERSIONS.md`, коммиты по смыслу.
## 7. Отдельный этап (требует решения): генератор
Причина — инцидент: создание `nubes_vc_org` с `v_ip_configure` даёт `inconsistent result after apply`
(платформа после create отдаёт `vIPConfigure: [{}]`, read-back перекрывает план).
Минимальные правки генератора (по Opus):
- **а)** modify-only параметр → **Optional+Computed** + `Deprecated` + не отправлять в `Update` (переход без breaking; удаление атрибута — только в следующем major);
- **б)** исключить modify-only поля из **create-read-back** (`InputField`).
**Не реализуем в этом этапе** — ждём решения пользователя (правка генератора задевает все сервисы).
> ⚠️ По ревью Opus (2026-09-24) пункт **§7б — обязательное условие**, а не опциональное:
> без исключения modify-only из create-read-back при переходном варианте будет борьба за поле
> между instance-ресурсом и модификатором. Пункт остаётся обязательным follow-up.
>
> 📌 Раунд 3: §7б выделяется в **отдельный релиз A** (универсально, схема не меняется, non-breaking,
> полностью закрывает инцидент `inconsistent result` на create vc_org). Пункты §7а + §7в — **релиз B**
> вместе с новыми ресурсами.
## 8. Вопросы для ревью Opus
1. Верно ли, что `RunInstanceOperationUniversalByCode` дозаполняет незаданные параметры из **live `state.params`**
(а не из дефолтов формы)? Если да — SNAT-ресурс может шлать только 372. Если нет — нужен явный pre-read+merge.
2. Delete для «весь массив»: слать `[{name, count:"0"}]` (проверено тестом) или `[]` (не проверено)? Что безопаснее
и не оставит ли `[]` элемент в state платформы?
3. Read-нормализация: считать ли `count="0"` и `[{}]` одним состоянием «пусто»? Не даст ли это ложный дрейф
при плановом уменьшении 3 → 0?
4. Переходный вариант (Deprecated + Optional+Computed, instance больше не шлёт параметр): не появится ли дрейф,
когда значение выставил модификатор, а instance-ресурс его только читает?
5. Достаточно ли `depends_on` (SNAT → org-IP) для корректного destroy, если org-IP-модификатор должен
уничтожиться **до** эджа? Нужны ли дополнительные рёбра?
---
## 9. Ревью Opus (2026-09-24, отдельный чат)
**Вердикт фактуры:** оба документа (план и `HAR_FRESH_CREATE_2026-09-24.md`) проверены по коду — факты верны,
ссылки на пути точны.
**Ответы на вопросы §8:**
1. **Подтверждено кодом.** `operation_run_bycode.go:108-142` дозаполняет все незаданные параметры по приоритету
**live `state.params` → `paramValue` формы → `defaultValue`**; если ничего нет — параметр пропускается.
SNAT-ресурс может шлать только 372, pre-read+merge НЕ нужен.
2. Слать `[{name, count:"0"}]`. `[]` не проверен, риск пустого payload/reset.
3. `count="0"`, `[{}]`, пустой массив — одно состояние «пусто» при Read. Иначе `[{}]` после create даёт ложный
дрейф; и для случая 3→0 нужен нормализующий planmodifier.
4. **Дрейф возможен** в переходном варианте (борьба за поле с read-back instance-ресурса) → §7б обязателен.
5. `depends_on` достаточно: TF развернёт граф, SNAT уничтожится до org-IP. Доп. рёбер не нужно при условии,
что оба модификатора зависят от `nubes_vc_nsxt`, а кластер — от SNAT.
**Замечания кодеру:**
- Два новых ресурса **не закрывают** инцидент `inconsistent result` на `nubes_vc_org` (Required-поле остаётся):
§7 — обязательный follow-up, не «потом».
- `count` в payload — **строка** `"0"` (в HAR всегда строка); зафиксировать тип явно.
- Стенд: орга **`saas`**, иначе коллизия `WZ03709-iaas`.
**Фиксатор:** эпоха `kind: modifier` отменена — ветку не переиспользовать; новые ресурсы hand-written
в `resources_core`, без реестра модификаторов.
---
## 10. Раунд 3 — вопрос Опусу: «это не поломает ничего?» (составлен 2026-09-24)
**Контекст (факт).** Правка шаблона `templates/instance.go` действует на все ресурсы. Замер по
`generated/dev/resources_yaml/*.yaml`: modify-only параметры есть только у **5 сервисов** —
`19_vc_org` (`vIPConfigure`), `22_vc_nsxt` (`ipSpaceName`), `12_s3` (`maxBucketsPerUser`,
`maxObjectsPerBucket`, `maxSizeGbPerUser`), `90_postgres` (`refreshCert`), `109_zones_v2` (`records`).
Цель правки — только первые два; у остальных трёх это рабочие атрибуты `Update`.
**Вопросы:**
1. **Критерий отбора.** Предлагается признак в спеке (`owned_by_modifier: true`). Это доменная метка в
универсальном YAML, что противоречит прежнему канону «YAML без доменных меток». Какой критерий корректен
в вашей архитектуре: spec-флаг, «required только в modify» (тогда ловится `vIPConfigure`, но **не**
`ipSpaceName` — он `required: false`), или явный список в генераторе?
2. **Безопасность (б)** (исключить modify-only из create-read-back): безопасно ли это для всех 5 сервисов,
или у s3/postgres/zones read-back нужен (иначе drift/потеря значения в state)?
3. **Поведение для существующих конфигов.** У тех, кто уже пишет `v_ip_configure`/`ip_space_name` в `.tf`,
после (в) модификация молча перестанет отправляться. Правильно ли молчание, или нужно явное падение
(ошибка «параметр управляется ресурсом `…ip_allocation`») — и как это сделать, если схема общая?
4. **Снятие Required у 5 сервисов** — не ломает ли `UseStateForUnknown`/JSON-planmodifier и не порождает
ли drift у тех, у кого поле было обязательным и уже заполнено?
5. **Порядок релиза.** Правильно ли разводить: релиз A — только (б) (чинит create орги, ничего больше
не трогает), релиз B — (а)+(в) вместе с новыми ресурсами-модификаторами?
---
## 11. Ответы Opus (раунд 3)
1. **Критерий отбора.** Структурный признак «modify-only = есть в `modifyParams`, нет в `createParams`»
(симметрично `ComputeCreateOnly`) — факт спеки, но он ловит **все 5** сервисов и не отличает
«управляется модификатором» от «рабочий Update-атрибут». «Required только в modify» неполон
(пропускает `ipSpaceName`, `required:false`). **Автопризнака не существует — это доменное знание.**
`owned_by_modifier: true` в пер-сервисном YAML — отвергнуть (нарушает канон);
правильно — **явный список в конфиге генератора**.
2. **Безопасность (б): безопасно для всех 5.** Read-back в create кладёт в state пост-create дефолт
(`[{}]`), которого юзер не задавал — это и есть источник `inconsistent result`. Create их и так не шлёт.
**Steady-state Read и Update read-back их по-прежнему перечитывают**, поэтому дрейф не теряется;
(б) убирает только бессмысленную перезапись сразу после create. s3/postgres/zones не страдают.
3. **Существующие конфиги.** Жёстко падать нельзя (схема общая, «владелец» — доменное знание, сломает state).
Правильно — `Deprecated` с текстом «управляется ресурсом `…ip_allocation`» → warning на каждом plan.
Молчаливое прекращение отправки — плохой UX, не делать. Удаление атрибута — только в следующий major.
4. **Снятие Required.** Затрагивает только `vIPConfigure` (`ipSpaceName` уже Optional).
`Optional+Computed` — штатный безопасный переход; `UseStateForUnknown` гасит unknown и drift не создаёт;
у заполненных полей значение удержится через read-back. Борьба за поле снимается (б)+(в).
5. **Порядок релиза — подтверждён:**
- **A — только (б):** универсально, схема не меняется, non-breaking, **полностью закрывает** инцидент
`inconsistent result` на create `nubes_vc_org`; s3/postgres/zones не трогает.
- **B — (а)+(в) + новые ресурсы** (Deprecated на delegated-параметры, отцеп от read-back/send).
**Следствие для наших решений:** критерий «кто делегируется» задаётся явным списком в конфиге генератора;
работа разбивается на релиз A (маленький, безопасный) и релиз B (ресурсы + отцепка).
---
## 12. ⚠️ Уточнение пользователя (2026-09-24): оргу делаем РУКАМИ в ЛК
**Факт:** орга создаётся вручную в ЛК и **в Terraform не заводится** — она одна на всё.
В tf она используется только как uid для модификаций.
**Что это меняет:**
1. `nubes_vc_org` в конфигурации **не используется** → дефект «`inconsistent result after apply` при create орги»
для этой задачи **не блокер** (остаётся латентным дефектом ресурса).
2. **Релиз A (правка create-read-back) становится необязательным** для цепочки Штурвала.
3. `nubes_vc_nsxt`: править генератор **тоже не нужно** — достаточно **не задавать** `ip_space_name` в `.tf`.
Атрибут Optional+Computed: SNAT выставит модификатор, read-back подхватит значение в state, дрейфа не будет.
4. Итог: для задачи нужны **только два новых ресурса** (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`),
оба адресуются по uid родителя. Правки генератора (§7, релизы A/B) — **отдельная тема**, не вход в эту работу.
**Открытый вопрос:** эдж (`nubes_vc_nsxt`) создаётся Terraform или тоже руками? На состав работ не влияет
(в обоих случаях нужны те же два ресурса), влияет только на пример конфигурации.
---
## 13. Статус работ (обновлено 2026-09-24)
**Сделано:**
- ✅ Проверка по коду: `RunInstanceOperationUniversalByCode` дозаполняет незаданные параметры
(live → paramValue → default) — частичный payload безопасен.
- ✅ `nubes_vc_org_ip_allocation` — `provider/internal/resources_core/org_ip_allocation_resource.go`
(коммит `22cf259`) + unit-тесты нормализации (`[{}]` → «пусто»).
- ✅ `nubes_vc_nsxt_snat` — `provider/internal/resources_core/nsxt_snat_resource.go` (коммит `80d82a1`).
- ✅ Регистрация в `provider/internal/provider/provider.go` (коммит `73a7459`).
- ✅ Пример конфигурации: `tf_examples/modify_resources/` (README, `main.tf`, `terraform.tfvars.example`).
⚠️ Каталог `tf_examples/` в `.gitignore:16` — пример локальный, как и остальные примеры в этом каталоге.
- ✅ Публичная страница: `docs/curated/modifiers/org_ip_and_snat.md` + nav (коммит `62abcd6`).
- ✅ Ветка-снимок состояния: `save/state-before-modify-resources-2026-09-24`.
- ✅ `go build` / `go vet` / `go test ./...` — зелёные.
**Не сделано (ждёт команды пользователя):**
- ⏳ Живой прогон на dev (`FullPipe`, орга `saas`; `apply` — только пользователь).
- ⏳ Бамп версии провайдера, сборка и заливка (`TOOLS/scripts/03_build_and_upload_provider.sh`).
- ⏳ Публикация документации (`04_build_and_publish_docs.sh`).
- ⏳ Решение по правке генератора (релизы A/B, §7) — отдельная тема.
@@ -0,0 +1,831 @@
# Ревью Opus: два новых ресурса-модификатора (2026-09-24)
> Что приложено: полный код двух новых ресурсов, тестов, фрагмент регистрации, известные проблемы и вопросы.
> Репо: `tf_provider`, коммиты `22cf259`, `80d82a1`, `73a7459`. Провайдер DEV `2.0.18` собран и залит.
> **Просьба: ревью полное, включая то, что я не вижу. Код не писался под ревью — можно предлагать переписать.**
## 1. Контекст
- Организация Cloud Director (сервис 19) и сетевой шлюз периметра (сервис 22) создаются **вручную в ЛК**.
В Terraform их нет — адресуются по `uid`.
- В схемах `nubes_vc_org` / `nubes_vc_nsxt` modify-параметры **есть** (генератор мержит create+modify),
но `Create` их не отправляет → в одном `apply` цепочку не собрать. Поэтому сделаны два отдельных ресурса,
которые делают только `modify`.
- Орга и эдж — единственные ресурсы своего типа (одна орга на realm, один эдж на vDC).
## 2. Известный баг (найден после заливки, ещё НЕ исправлен)
`formatVipConfigure` (файл 1, строка 348) собирает `{"name":…,"count":…}`.
Terraform `jsonencode` сортирует ключи по алфавиту:
```
$ terraform console
> jsonencode([{name="internet-ipv4-v1", count="3"}])
"[{\"count\":\"3\",\"name\":\"internet-ipv4-v1\"}]"
```
`JsonNormalize` (приложен ниже) только компактит JSON, порядок ключей не меняет.
→ план (`count,name`) ≠ state после Read (`name,count`) → **вечный diff**.
## 3. Риски, которые я не могу проверить без живой платформы
1. `vip_configure` и `ip_space_name` — **Required**, а `Read` может вернуть `null` («аллокации нет»).
Корректно ли это для Required-атрибута (не будет ли ошибки/вечного diff)?
2. `Update` **не делает read-back** после modify — не приведёт ли это к inconsistent result / дрейфу.
3. Имена live-ключей (`vIPConfigure`, `ipSpaceName`) взяты из HAR ЛК, не сверены с кодом.
4. `setSnat`: пустая строка молча заменяется на `no-needed` (скрытое поведение).
5. CRUD живым прогоном **не проверялся вообще** — только `go build`/`vet`/юнит-тесты парсинга.
## 4. Приложенный код
### 4.1. `provider/internal/resources_core/org_ip_allocation_resource.go`
```go
package resources_core
import (
"context"
"encoding/json"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &OrgIpAllocationResource{}
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
//
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
// и адресуется здесь по uid.
//
// Семантика операции — replace всего массива: переданное значение полностью заменяет
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
type OrgIpAllocationResource struct {
client *core.UniversalClient
}
type OrgIpAllocationModel struct {
ID types.String `tfsdk:"id"`
OrgUID types.String `tfsdk:"org_uid"`
VIPConfigure types.String `tfsdk:"vip_configure"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
type vipAllocation struct {
Name string
Count string
}
func NewOrgIpAllocationResource() resource.Resource {
return &OrgIpAllocationResource{}
}
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
}
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"org_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"vip_configure": schema.StringAttribute{
Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка.",
PlanModifiers: []planmodifier.String{
JsonNormalize(),
},
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
"`count=0` по каждому элементу).",
},
},
}
}
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
// Организации больше нет — ресурс тоже не нужен.
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
raw, ok := live["vIPConfigure"]
if !ok {
// Платформа не вернула параметр — считаем, что аллокации нет
// (у свежей орги ключ присутствует со значением `[{}]`, что тоже «пусто»).
state.VIPConfigure = types.StringNull()
} else {
items, parseErr := parseVipConfigure(raw)
if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return
}
if len(items) == 0 {
state.VIPConfigure = types.StringNull()
} else {
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
}
}
state.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("не удалось проверить существование организации %s: %s", orgUID, err),
)
return
}
if remove {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
)
return
}
unlock := r.client.LockInstance(orgUID)
defer unlock()
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
items := []vipAllocation{}
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
)
return
}
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
zero := make([]vipAllocation, 0, len(items))
for _, item := range items {
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
}
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(zero),
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"Квота IP обнулена",
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
)
}
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("org_uid"), uid)...)
}
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID.ValueString())
if uid == "" {
return fmt.Errorf("org_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
items, err := parseVipConfigure(vipConfigure.ValueString())
if err != nil {
return err
}
if len(items) == 0 {
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
}
unlock := r.client.LockInstance(uid)
defer unlock()
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(items),
})
}
// parseVipConfigure разбирает значение параметра vIPConfigure.
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
func parseVipConfigure(raw string) ([]vipAllocation, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
}
out := make([]vipAllocation, 0, len(items))
for _, item := range items {
name := ""
if v, ok := item["name"]; ok && v != nil {
name = strings.TrimSpace(fmt.Sprint(v))
}
if name == "" {
continue
}
count := "0"
if v, ok := item["count"]; ok && v != nil {
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
count = parsed
}
}
out = append(out, vipAllocation{Name: name, Count: count})
}
return out, nil
}
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
// (порядок ключей как в HAR; count — строка).
func formatVipConfigure(items []vipAllocation) string {
if len(items) == 0 {
return "[]"
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count)) // ← строка 348, ИСТОЧНИК БАГА
}
return "[" + strings.Join(parts, ",") + "]"
}
```
### 4.2. `provider/internal/resources_core/nsxt_snat_resource.go`
```go
package resources_core
import (
"context"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &NsxtSnatResource{}
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
//
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
//
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
type NsxtSnatResource struct {
client *core.UniversalClient
}
type NsxtSnatModel struct {
ID types.String `tfsdk:"id"`
NsxtUID types.String `tfsdk:"nsxt_uid"`
IpSpaceName types.String `tfsdk:"ip_space_name"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// noNeededIpSpace — каноническое значение «SNAT не нужен».
const noNeededIpSpace = "no-needed"
func NewNsxtSnatResource() resource.Resource {
return &NsxtSnatResource{}
}
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
}
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
"Значение `no-needed` выключает SNAT.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"nsxt_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ip_space_name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
"`ipSpaceName = \"no-needed\"`).",
},
},
}
}
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// Ключа ipSpaceName нет, пока SNAT ни разу не включали (HAR fresh-create),
// поэтому отсутствие ключа = null. Значение "no-needed" (SNAT выключен) — реальное.
if raw, ok := live["ipSpaceName"]; !ok || strings.TrimSpace(raw) == "" {
state.IpSpaceName = types.StringNull()
} else {
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
}
state.ID = types.StringValue(nsxtUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("не удалось проверить существование шлюза %s: %s", nsxtUID, err),
)
return
}
if remove {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
)
return
}
unlock := r.client.LockInstance(nsxtUID)
defer unlock()
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
"ipSpaceName": noNeededIpSpace,
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"SNAT выключен",
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
)
}
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
}
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
uid := strings.TrimSpace(nsxtUID.ValueString())
if uid == "" {
return fmt.Errorf("nsxt_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
value := strings.TrimSpace(ipSpaceName.ValueString())
if value == "" {
value = noNeededIpSpace
}
unlock := r.client.LockInstance(uid)
defer unlock()
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
// а не с live-состоянием инстанса — можно ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"ipSpaceName": value,
})
}
```
### 4.3. `provider/internal/resources_core/org_ip_allocation_test.go`
```go
package resources_core
import "testing"
func TestParseVipConfigure_EmptyAndBroken(t *testing.T) {
cases := []struct {
name string
raw string
want int
}{
{"пустая строка", "", 0},
{"пустой массив", "[]", 0},
{"пустой элемент (свежая орга)", "[{}]", 0},
{"только name без count", `[{"name":"internet-ipv4-v1"}]`, 1},
{"элемент без name", `[{"count":"3"}]`, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseVipConfigure(tc.raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != tc.want {
t.Fatalf("получено %d элементов, ожидалось %d (%+v)", len(got), tc.want, got)
}
})
}
}
func TestParseVipConfigure_CountAsString(t *testing.T) {
got, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":4}]`)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != 1 || got[0].Count != "4" {
t.Fatalf("ожидался count=\"4\", получено %+v", got)
}
}
func TestFormatVipConfigure_Canonical(t *testing.T) {
got := formatVipConfigure([]vipAllocation{{Name: "internet-ipv4-v1", Count: "3"}})
want := `[{"name":"internet-ipv4-v1","count":"3"}]` // ← ожидание неверное: Terraform даёт count,name
if got != want {
t.Fatalf("получено %q, ожидалось %q", got, want)
}
if empty := formatVipConfigure(nil); empty != "[]" {
t.Fatalf("для пустого списка ожидалось \"[]\", получено %q", empty)
}
}
func TestParseVipConfigure_RoundTripIsStable(t *testing.T) {
raw := `[{"name":"internet-ipv4-v1","count":"4"}]`
items, err := parseVipConfigure(raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if again := formatVipConfigure(items); again != raw {
t.Fatalf("round-trip не стабилен: %q → %q", raw, again)
}
}
func TestParseVipConfigure_InvalidJSON(t *testing.T) {
if _, err := parseVipConfigure(`{"name":"x"}`); err == nil {
t.Fatal("ожидалась ошибка на объект вместо массива")
}
}
```
### 4.4. Регистрация — `provider/internal/provider/provider.go`
```go
func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource {
resources := resources_gen.AllResources()
resources = append(resources, resources_core.NewServiceOperationResource)
// Ресурсы-модификаторы для операций, которых нет в create-схеме ресурсов-инстансов.
// Организация и шлюз создаются вручную в ЛК, поэтому адресуются по uid, а не ссылкой на ресурс.
resources = append(resources, resources_core.NewOrgIpAllocationResource)
resources = append(resources, resources_core.NewNsxtSnatResource)
return resources
}
```
### 4.5. Существующий plan-modifier `JsonNormalize` (`resources_core/json_planmodifier.go`)
```go
// PlanModifyString сворачивает JSON до компактного вида.
// Если значение не является корректным JSON — оставляет как есть, не добавляет ошибку.
func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
if req.PlanValue.IsUnknown() || req.PlanValue.IsNull() {
return
}
raw := req.PlanValue.ValueString()
var buf bytes.Buffer
if err := json.Compact(&buf, []byte(raw)); err != nil {
return
}
resp.PlanValue = types.StringValue(buf.String())
}
```
## 5. Вопросы на ревью
1. **Как правильно закрыть баг порядка ключей** — (а) сортировать ключи в обоих местах (`count`,`name`);
(б) свой plan-modifier, канонизирующий ввод через parse→canonical, чтобы любой порядок от юзера сходился;
(в) отказаться от JSON-строки и сделать nested-атрибут (тогда `jsonencode` у юзера не нужен)?
Что правильно и что меньше ломает?
2. **`Required` vs `Optional+Computed`** для `vip_configure` / `ip_space_name`: `Read` может вернуть «пусто».
Корректно ли писать `null` в state для Required-атрибута, или это неверно и надо другой тип?
3. Нужен ли **read-back после Create/Update** (сейчас его нет)? Не приведёт ли отсутствие read-back
к inconsistent result или наоборот — к тому, что мы храним в state не то, что на платформе?
4. **Delete**: последовательность «`ShouldRemoveFromState` → `LockInstance` → `ByCode`» корректна?
Ошибки API при destroy — warning (как сейчас) или error?
5. **Идемпотентность**: сознательно не используем `ByIdempotent`, потому что его сравнение идёт с `paramValue`
формы, а не с live. Согласен, или есть другой способ не гонять лишний modify?
6. **Имена live-ключей** (`vIPConfigure`, `ipSpaceName`): где проверить, чтобы не полагаться на HAR?
7. **Что ещё в этом коде сломается**, чего я не вижу? Особенно: имена/семантика диагностик,
поведение `void`-возвратов, `RemoveResource` vs `RemoveResource`-в-Delete, импорт.
---
# 6. Ответ Opus на ревью (2026-09-24)
**Вердикт:** главный блокер — **баг порядка ключей + `Required` с `null`**. Оба чинятся
канонизирующим plan-modifier'ом. Всё остальное (Configure/Import/Lock/diagnostics) — корректно.
**По вопросам:**
1. **Баг порядка ключей → вариант (б):** plan-modifier, прогоняющий значение через
`parseVipConfigure → formatVipConfigure`. Сортировка ключей (а) не спасает: `jsonencode` юзера даст
`count,name`, а `formatVipConfigure` — `name,count`; минус только nested (в). Чинить и тест
`TestFormatVipConfigure_Canonical` (ожидание в нём неверное).
2. **`Required` + `null` в `Read` = источник `Provider produced inconsistent result`.** После apply
state обязан совпасть с планом. Правильно: **не писать `null`**, хранить конфиг-значение; либо делать
атрибут `Optional`, а не `Required`.
3. **Read-back не обязателен**, но **канонизация ввода обязательна** — иначе inconsistent-result при первом
`refresh` (там и всплывёт баг п.1).
4. **Delete:** последовательность `ShouldRemoveFromState → Lock → ByCode` корректна. Но ошибки API при destroy
должны быть **error, а не warning**: иначе реальный сбой обнуления квоты замалчивается, ресурс уходит из
state, квота висит. Warning — только для «родителя уже нет».
5. **Идемпотентность:** `ByCode` выбран правильно (`ByIdempotent` сравнивает с `paramValue` формы, ложно
пропустит modify).
6. **Имена live-ключей:** в коде провайдера их нет — только HAR; сверить можно исключительно живым
`GetInstanceStateParams` (прогон). Пока это риск, а не факт.
7. **Дополнительно:**
- `setSnat`: тихая подмена `""` → `no-needed` — заменить на валидацию (ошибку).
- `nsxt_snat.Read`: `no-needed` пишется в state как реальное значение — согласовать с решением п.2.
- `applyAllocation` при пустом массиве → error, значит «снять всё» через `vip_configure` нельзя
(только destroy) — **задокументировать** в описании атрибута.
- Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым.
## Итог по ревью: что сделано и где ревью ошиблось
**⚠️ Совет Opus (вариант «б», канонизация в plan-modifier) — НЕВЕРЕН.** Plan-modifier не имеет права
менять значение пользовательского атрибута: Terraform отвечает
`Provider produced invalid plan: planned value does not match config value`.
Это правило описано в нашем же сгенерированном коде (`22_vc_nsxt_resource.go`, комментарий в `ModifyPlan`).
Проверено живым `terraform plan` 2026-09-24 (ошибка воспроизведена).
Правильное решение (коммит `807dfde`):
- plan-modifier удалён полностью (`JsonNormalize` тоже снят — он компактит, то есть тоже менял бы значение);
- в `Read` — смысловое сравнение `vipAllocationsEqual`: если смысл совпал (порядок ключей/формат не важны),
значение пользователя НЕ переписывается; пишется только реальный дрейф.
**Выполнено корректно:**
1. ✅ Убран plan-modifier, менявший пользовательское значение; сравнение — смысловое (коммит `807dfde`).
2. ✅ `null` в `Required`-атрибуты не пишется — при пустом live сохраняется текущее значение state.
3. ✅ `Delete`: ошибки API → `AddError`; warning только для отсутствующего родителя.
4. ✅ `setSnat`: валидация пустой строки вместо тихой подмены на `no-needed`.
5. ✅ Задокументировано: «снять всё» через `vip_configure` нельзя, только `destroy`.
6. ✅ Тесты: смысловое сравнение (порядок ключей, разный count/имя, пустая аллокация).
7. ⚠️ Релиз `2.0.19` залит, но **содержит сломанный plan-modifier** — для работы из реестра нужен `2.0.20`.
@@ -0,0 +1,78 @@
# HAR fresh-create: что происходит при создании орги/эджа (dev, 2026-09-24)
> Источники: `HAR/globak.har` (ЛК: создание орги + vDC + эджа, затем два modify),
> `HAR/org_already exists.har` (отказ создания орги из-за коллизии имени).
> Стенд: `lk-api-gateway-dev.ngcloud.ru`, realm `sandbox.nubes.ru`.
> Цель разбора: понять, что реально приходит в `state.params` после `create`
> (влияет на read-back в сгенерированных ресурсах).
## 1. Поток создания в ЛК
Инстанс создаётся **в два шага**, не одним запросом:
1. `POST /instances` — тело **только** `{"serviceId":N,"displayName":"…","descr":""}`. Никаких параметров.
2. `POST /instanceOperations` — `{"instanceUid":"…","operation":"create"}` → возвращает `instanceOperationUid`.
3. `POST /instanceOperationCfsParams` — по одному запросу на параметр: `{"paramValue":"…","instanceOperationUid":"…","svcOperationCfsParamId":NNN}`.
4. `GET /instanceOperations/{opUid}/validate-cfs`.
5. `POST /instanceOperations/{opUid}/run`.
6. Поллинг `GET /instanceOperations/{opUid}` до `dtFinish`.
Это в точности тот же набор эндпоинтов, что использует наш провайдер (`core/operation_run.go`, `operation_cfs.go`).
## 2. Параметры операций (из HAR)
| Сервис | Операция | Параметры |
|---|---|---|
| Орга (19) | create | `418 resourceRealm=sandbox.nubes.ru`, `556 organizationType`, `1125 orgSuffix` |
| vDC (21) | create | `30`, `746`, `335`, `397`, `557`, `558`, `361` (+ `8` = uid орги) |
| Эдж / vc_nsxt (22) | create | `621 vdcType=vdc`, `8 vdcUid`, `622`, `340 needEnableAVI`, `341 virtualServicesCount`, `825 qosProfile`, `1110 routedNetConfiguration` |
| Орга (19) | modify (207) | `662 vIPConfigure = [{"name":"internet-ipv4-v1","count":"3"}]` |
| Эдж (22) | modify (111) | `368 needEnableAVI`, `369 virtualServicesCount=4`, `856 qosProfile`, **`372 ipSpaceName=internet-ipv4-v1`**, `1112 routedNetConfiguration` |
`372 ipSpaceName` **не участвует в create** — только в modify. Ровно как в нашем `Update`
(`22_vc_nsxt_resource.go`), который шлёт 368/369/372/856/1112.
## 3. `state.params` до и после modify
Ответ `GET /instances/{uid}`: параметры лежат в **`instance.state.params`**
(`instance.params` = `null`). Наш `GetInstanceStateParams` (`core/instance_params.go:35-45`)
читает именно этот путь — то есть read-back их видит.
| Инстанс | Сразу после create | После modify |
|---|---|---|
| Орга `df5ec5f2…` («kontra») | `{"admins":[], "vIPConfigure":[{}], "resourceRealm":"sandbox.nubes.ru", "organizationType":"saas"}` | `vIPConfigure=[{"name":"internet-ipv4-v1","count":"3"}]`, state version 3 → 4 |
| Эдж `ad0ab577…` («tedj») | `vdcUid`, `vdcType`, `qosProfile="QoS-100Mbit"`, `vdcGroupUid=""`, `needEnableAVI=true`, `virtualServicesCount="1"`, `routedNetConfiguration` — **ключа `ipSpaceName` НЕТ** | `ipSpaceName="internet-ipv4-v1"`, `virtualServicesCount="4"`, version 1 → 2 |
Ключевое: у орги `vIPConfigure` **присутствует и равен `[{}]`** (пустой элемент);
у эджа `ipSpaceName` **отсутствует** до первого modify.
## 4. Провал операции приходит внутри тела, а не HTTP-кодом
`HAR/org_already exists.har`: создание орги с `organizationType=iaas` и `orgSuffix=suff`:
- `POST /instances` → 201, `POST /instanceOperations` → 201, `validate-cfs` → 204, `run` → 201;
- финальный `GET /instanceOperations/{opUid}`: `submitResult="201"`, `isSuccessful=false`,
`errorLog="Организация с именем 'WZ03709-iaas' уже существует в рамках ресурсной платформы sandbox.nubes.ru"`.
Вывод: **ошибку операции нужно читать из `errorLog`/`isSuccessful`** поллинга; HTTP-код ничего не скажет.
Дополнительно: имя орги формируется как `<suffix>-<тип>` (`WZ03709-iaas` / `WZ03709-saas`),
то есть в одном realm — по одной орге каждого типа; повтор даёт ту же ошибку.
## 5. Выводы для нашего провайдера
1. `nubes_vc_org.v_ip_configure` — **Required** в схеме (generator мержит create+modify, `loader.go:96`),
но при `Create` не отправляется, а read-back после create вернёт `[{}]` вместо планового значения
→ риск `Provider produced inconsistent result after apply` на создании орги. **Прогоном не проверено.**
2. `nubes_vc_nsxt.ip_space_name` — Optional+Computed: при create ключа в state нет, значение сохраняется
в state, но **SNAT не включается**; включается только следующим `apply` (Update → 372). **Прогоном не проверено.**
3. `RefreshResourceState` (`resources_core/state_refresh.go`) перезаписывает поля из `state.params`;
для modify-only параметров это поведение опасное — в create его включать не следует (универсальная правка генератора).
4. Из п.1–2 следует, что одной правкой «добавить два ресурса-модификатора» инцидент может не закрыться:
схема `nubes_vc_org` останется с Required-полем.
## 6. Ограничения разбора
- `apply`/`plan` не запускались: пункты 1–2 — вывод из кода + HAR, не подтверждены живым прогоном.
- Проверено на одном стенде (dev), одной орге (`NarodOrg` — во втором HAR имя `WZ03709-iaas` уже занято).
- `qosProfile` в create ЛК отправляет пустым, после modify в state = `QoS-100Mbit`.
@@ -0,0 +1,128 @@
# Q&A с Opus: дизайн ресурсов-модификаторов (2026-09-24)
> Кто: вопросы составлены нами (Copilot), ответы — Opus (внешний агент, по разрешению пользователя).
> Контекст: решено делать два ресурса-модификатора (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`).
> Статус: ответы приняты к сведению, **код не писался**, часть утверждений Opus мною не проверена (пометки ниже).
## Вопросы и ответы
### 1. Инварианты Read/Delete ресурса-модификатора
**Ответ Opus:**
- Read: `RemoveResource` только если родитель исчез (404/deleted) — у нас есть `ShouldRemoveFromState`
(Opus ссылается на `modifier.go`). Расхождение значения параметра ≠ повод удалять ресурс: это дрейф,
обновить поле в state.
- Delete = inverse modify (`count=0` / `needEnableAVI=false` / `ipSpaceName="no-needed"`) — «шаблон
`DeleteStrategy=inverse` + `override` уже реализован».
- Если родитель уже удалён: inverse пропустить, ресурс убрать из state (no-op + Warning), не падать на ошибке API.
### 2. Reset-to-default в `*WithDefaults`
**Ответ Opus:** защита «уже встроена»: и `RunInstanceOperationUniversalWithDefaults` (`operation_run.go:138`),
и by-code путь (`operation_run_bycode.go:108`) досылают незаданные параметры с приоритетом
**live `state.params` → `paramValue` формы → `defaultValue`**. Достаточно шлать только `ipSpaceName`.
Отдельный pre-read live + merge делать не нужно; `ByCode`/`ByIdempotent` — не нужны.
Дополнительно `RunOperationByCodeIdempotent` (`check_before_run`) сверяет desired == current и пропускает лишний run.
### 3. Генератор: modify-only параметр с `required: true`
**Ответ Opus (минимальный набор):**
- **(а)** modify-only → всегда Optional (снять Required в схеме). Обязательно.
- **(б)** исключить modify-only из create-read-back (не добавлять его `InputField` в Create/Read). Обязательно.
- **(в)** «после create догонять modify» — **не нужно**: это ответственность отдельного modifier-ресурса.
- Breaking: снятие Required — не breaking (Optional шире). Breaking — если **удалить** атрибут из схемы
instance у тех, кто его уже прописал в `.tf`. Формулировка Opus: «modify-only параметров в схеме instance
быть не должно вовсе — их место в modifier-ресурсе».
### 4. Диагноз «inconsistent result after apply» на создании орги
**Ответ Opus: подтверждает.** `state_refresh.go`, цикл `inputs`: берёт `paramsMap["vIPConfigure"]` из
`state.params` (платформа отдаёт `[{}]`), через `setFieldValue`/`ParseString` перекрывает план; для
Required-атрибута TF требует final == config → ошибка. Корректно: не читать modify-only обратно в Create
(п.3б) и вернуть запланированное значение, либо Optional+Computed со схлопыванием `[{}]`→null.
### 5. Порядок destroy
**Ответ Opus:** явный `depends_on` нужен — связь между org-IP и SNAT идёт по **имени** ipSpace, ребра графа
TF не видит. Цепочка: `vdc → org → org-IP → edge → SNAT → кластер`; при корректных `depends_on` destroy
пойдёт в обратном порядке. Обязательные рёбра: SNAT → org-IP, modifier → родитель. Достаточно при условии,
что inverse-Delete терпит уже удалённого родителя (п.1).
### 6. Трактовка `[{}]` в Read
**Ответ Opus:** `[{}]` = «не выделено», нормализовать в null/пусто. `count=0` и `[{}]` — одно состояние
«пусто», иначе ложный дрейф на каждом plan.
## Мои замечания к ответам (не проверено кодом, требует внимания)
1. **Opus опирается на machinery отменённого захода.** Он говорит про `modifier.go`, `DeleteStrategy=inverse`,
`override`, «уже реализовано». Это шаблон генератора из эпохи `kind: modifier`, которую мы **сознательно
отменили** (см. баннер LEGACY в `NOTES/20_prompts/**`, `docs/60_strategy/modifier_resources_ideology_and_specification.md`).
Ответы про «уже встроено» нельзя принимать как готовое решение — это код отменённой ветки.
2. **Противоречие внутри п.3:** сначала «modify-only → всегда Optional (оставить в схеме instance)»,
потом «modify-only в схеме instance быть не должно вовсе». Это разные изменения: Optional+Computed vs удаление.
Нужно выбрать одно, иначе получим двух владельцев одного параметра (instance-ресурс и модификатор).
3. **Два владельца параметра.** Если `ip_space_name` остаётся в `nubes_vc_nsxt` **и** появляется
`nubes_vc_nsxt_snat`, Terraform не увидит конфликт: оба будут шлать 372. Значит, из `Update`
сгенерированного `nubes_vc_nsxt` параметр надо убирать — иначе fight/drift. В ответах Opus этого нет.
4. **П.2 не проверял сам.** Утверждение «приоритет live → paramValue → defaultValue уже встроен» противоречит
комментарию в `19_vc_org_resource.go` про reset-баг (`state_params["needEnableAVI"]="false"`, когда на
платформе `true`). Нужна проверка `operation_run.go:138` и `operation_run_bycode.go:108` по коду.
5. **Политика destroy для не-нашей орги.** Орга не в state (адресация по uid). При `destroy` конфигурации
родитель не удаляется — но org-IP-модификатор по §1 выполнит inverse (`count=0`). Нужно решение:
снимать квоту или оставлять (`keep_on_destroy`)— у Opus этого нет.
6. **Один элемент vs весь массив.** `vIPConfigure` — массив. Если ресурс управляет одним элементом
(по `ip_space_name`), то два ресурса на разные ipSpace возможны; если всем массивом — нет. `count=0`
как inverse предполагает поэлементную модель, но в ответах это не зафиксировано.
---
# Раунд 2 (те же сутки): ответы Opus на 5 уточняющих вопросов
### Про противоречие в п.3 (раунд 1)
Opus признал: это были две несовместимые опции.
- **Канон (цель):** modify-only параметра в схеме instance быть не должно — владелец отдельный modifier-ресурс.
- **«Всегда Optional»** — только переходный вариант, если параметр временно оставлен в instance.
- Одновременно оба тезиса не действуют.
### 1. Два владельца 372/662
Один владелец. Instance **перестаёт слать** 372/662: убрать из `ModifyParams` генератора
(не эмитить в `params` map в `instance.go:478`, Update). Незаданные параметры при этом не сбросятся —
досылаются из live `state.params` (см. п.2 раунда 1). Поле в instance остаётся максимум как read-back
(Computed) либо убирается вовсе.
### 2. Массив vs элемент
`vIPConfigure` — `array-map-fixed` с **replace-семантикой всего массива**: отправка `[{name,count}]`
перезаписывает массив целиком.
- **Один ресурс = весь массив** — просто и безопасно.
- Два ресурса на разные ipSpace — только с read→merge→send-full-array; без merge last-write-wins.
- **Рекомендация MVP Opus: один ресурс = весь массив.** Мультиресурс по имени — отдельная фича.
### 3. Destroy, когда родитель не наш
Флаг `keep_on_destroy` (bool, Optional):
- родитель жив и `keep_on_destroy=false` (дефолт) → inverse (`count=0`);
- родитель 404 / вне нашего контроля → пропустить + Warning (не падать);
- `keep_on_destroy=true` → всегда no-op + Warning.
### 4. Вывод атрибута из instance-схемы (не breaking)
Два шага:
- **сейчас**: `Deprecated: "..."` + **Optional+Computed** + прекратить отправку в modify (read-back остаётся);
- **следующий major**: удалить атрибут.
### 5. Тип атрибута в новом ресурсе
**String + JSON + `JsonNormalize()`** (как сейчас `v_ip_configure`), потому что:
- wire-формат `array-map-fixed` — JSON-строка;
- `json_planmodifier.go` — `planmodifier.String` (на list/nested не встанет);
- `RefreshResourceState` читает input-поля только как scalar string/bool/int.
Nested list даёт лучший UX, но требует нового кода в `state_refresh.go`. Для MVP — String+JsonNormalize.
## Мои замечания к раунду 2
1. **П.2 меняет интерфейс заявленного ресурса.** Мы планировали `nubes_vc_org_ip_allocation`
с `ip_space_name` + `count` (по элементу). Opus рекомендует «один ресурс = весь массив»
(list `{name,count}`). Это разные ресурсы по UX и по семантике Delete — требует решения пользователя.
2. **Проверяемость.** Утверждение про `instance.go:478` и про приоритет live при дозаполнении я не
проверял по коду — числовой якорь может быть неточным (в прошлом ответе он ссылался на
`modifier.go` отменённой ветки).
3. **`Deprecated` + `Optional+Computed`** — единственный вариант, который проходит без breaking, согласен;
но это правка сгенерированной схемы → правится в генераторе, не в `resources_gen/*.go`.
## Что дальше
- Решение пользователя по п.2 замечаний (элемент vs весь массив).
- Проверка по коду приоритета live-дозаполнения и строки `instance.go:478` (чтение, без правок).
- После решения — план реализации 2 ресурсов.
@@ -0,0 +1,307 @@
# Штурвал dev-00: диагностика, destroy-засада с квотой IP и дизайн «freeze on destroy» (2026-09-24)
> Источники: `kubectl` из локали (контекст `tazet@narod.ru@shturval-dev-00`), API ЛК dev
> (`https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc`, токен `secrets/narodDEV.token`), код провайдера
> (`provider/`), генератор (`TOOLS/resource-generator/`), конфиг стенда `DEV_STAND/FullPipe/`.
> Все выводы — только из этих источников; где не проверено, отмечено «не проверено».
---
## 1. Стенд и кластер
- Услуга **150 «Kubernetes кластер Штурвал»**, инстанс `shturval-dev`, uid `94627ff4-33a5-48f2-aca1-695741e0b6a2`.
Создан 24.09.2026 17:18:01, операция `create` завершена 17:36:58 (`isSuccessful=true`, `errorLog=null`).
- `state.out`: `webUrl=https://k8s.ngcloud.ru/clusters/shturval-dev-00/dashboard`,
`kubernetesApiAddress=185.247.187.146`, `ingressAddress=185.247.187.148`.
- Параметры: `clusterName=shturval-dev-00`, `vdcUid=d0937335-…` (`fullpipe-vdc`),
`nsxtUid=2C37FED1-E8F8-4A84-8434-7851C7C8B5D6` (эдж `fullpipe-edge`), `appVersion=2.14.0`,
`exLogging/exMonitoring/exLocalCsi/exVip/exUpdate/exIngress/exNamedCsi = true`, CP 1× `TKG 4CPU 8RAM` / 50 ГБ,
workers 1× `TKG 4CPU 8RAM` / 50 ГБ (`workers-shturval-dev`, labelDeck=true).
- kubeconfig: сервер `https://185.247.187.146:6443`; client v1.34.1, server v1.35.1; узлы 2× Ready
(control-plane + worker), Ubuntu 24.04.5, containerd 2.2.1.
- Организация `organ` (uid `57eeacd1-dc7f-4a52-b903-7e5f7d3c1164`, CD-имя `WZ01325-saas`, realm `sandbox.nubes.ru`).
### Состояние кластера (снимок 17:52 MSK)
- Подов 49 (готовых 44). Не-Running остались только подвисшие поды установщика:
`shturval-init-job-489mk`, `-98n4k`, `-vkwk7` (Error), `-l8457` (Unknown); рядом `-9587k` (Completed).
- Job `kube-system/shturval-init-job`: label `shturval.tech/init`, **без ownerReferences**,
`backoffLimit: 10`, `ttlSecondsAfterFinished: 86400`, nodeSelector `control-plane`,
образ `r.shturval.tech/shturval-install:2.14.0`, `/scripts/run.sh`. Итог: `failed: 4`, `succeeded: 1`,
завершён 14:31:46 UTC (17:31 MSK) → поды удалятся сами ~25.09 14:31 UTC.
- Причина падений (лог пода): `UPGRADE FAILED: failed to create resource: conversion webhook for
ops.shturval.tech/v1beta1, Kind=ShturvalRepoConfig failed: Post
"https://shturval-services-webhook-service.shturval-services-system.svc:443/convert?timeout=30s":
dial tcp 10.97.147.91:443: connect: operation not permitted`. То же в логе оператора:
`failed calling webhook "vshturvalupdate.kb.io" … connect: operation not permitted` — до готовности Cilium
webhook-и недоступны. Следующая попытка прошла (релиз `shturval-services.v3`), всё поднялось.
- Остальное зелёное: `shturvalserviceconfigs` 41/41 `ready=true` (24 в режиме `auto`, 17 в `absent`),
`nodeconfigitems` 4/4 `ready=true`, `nodeconfigs` 2/2, у всех 35 сервисов есть endpoints,
IngressClass `nginx` 1, ingress-controller 1/1.
### UI-счётчики (расшифровка)
| Колонка | Что это на самом деле |
|---|---|
| `Pods 44/48` | готовые/всего поды (48 = все поды минус `Completed`) |
| «Системные сервисы» | число `ShturvalServiceConfig` в режиме `auto`: 17/24 во время установки → 24/24 |
| «Конфигурация узлов» 4/4 | `nodeconfigitems.node.shturval.tech` `ready=true` |
| «Ingress» | домен `*.shturval-dev-00.ip-185-247-187-148.shturval.link`, **не** счётчик |
| ⚠️ на «Pods» | ровно 4 подвисших пода `shturval-init-job` |
Статус «Работает с ошибками» в первом снимке (31/53 подов) был снят во время установки; после догрузки — «Работает».
---
## 2. Что DevOps может сделать с мусором init-job
Ответ на вопрос «что выставить в настройках деплоя Штурвала»:
- В услуге 150 и в конфиге стенда таких ручек **нет** (есть только `vdc_uid`/`nsxt_uid`, `cluster_name`,
галочки `ex_*`, sizing/count, внешние адреса). `backoffLimit` и `ttlSecondsAfterFinished` зашиты
в манифест установщика платформы.
- Поэтому вариантов два: подождать самоочистку по `ttlSecondsAfterFinished: 86400`, либо тикет в команду
Штурвала: уменьшить TTL и/или не запускать установку компонентов до готовности Cilium
(иначе снова `operation not permitted` на webhook-ах).
---
## 3. Destroy стенда и засада с квотой IP
Порядок destroy: `nubes_vc_nsxt_snat.snat` → `nubes_vc_org_ip_allocation.org_ip` → `nubes_vc_nsxt.edge` → `nubes_vc_vdc.vdc`.
- `snat` удалился успешно (2m27s), отправив modify `ipSpaceName = "no-needed"` (warning «SNAT выключен»).
- `org_ip_allocation` упал:
`Error: Ошибка клиента — операция D9FB606D-C86E-4D30-A58D-44282C4508AE завершилась с ошибкой:
Кол-во зантяых Ip в тенанте 'WZ01325-saas': 2. Невозможно выставить параметр count ниже этого параметра`.
- Причина в коде: `Delete` аллокации при `keep_on_destroy = false` отправляет обратный modify
`vIPConfigure = [{"name":"internet-ipv4-v1","count":"0"}]`
(`provider/internal/resources_core/org_ip_allocation_resource.go:258-274`); при `keep_on_destroy = true`
ничего не отправляется (строки 212-215). В стенде сейчас `keep_on_destroy = false`
(`DEV_STAND/FullPipe/modifiers.tf:39` для квоты, `:29` для SNAT).
- Кто держит 2 адреса: инстанс кластера Штурвала — `.146` (API) и `.148` (ingress). `suspend` адреса
**не** освобождает (suspend выполнен 17:59:59 MSK успешно, `isDeleted=false`, `uptime=0`, адреса в `state.out` остались).
- Последствие упавшего destroy: прерван, до edge/vDC дело не дошло; в state остались `vdc`, `edge`,
`org_ip_allocation`, а `snat` уже удалён — «рваное» состояние.
### Метаданные платформы (проверено через API ЛК)
- Обязательные заголовки: `Authorization: Bearer <secrets/narodDEV.token>`,
браузерный `User-Agent`, `Referer: https://deck-dev.ngcloud.ru/` — без них DDoS-Guard отдаёт `403 Forbidden`.
- Эндпоинты: `GET /instances?page=1&size=200`, `GET /instances/{uid}`; параметры — в
`instance.state.params` (верхнеуровневый `instance.params = null`), статус — `explainedStatus`.
- `availableOperations`: кластер — `delete, modify, suspend, resume, reconcile, create_user, delete_user`;
vDC — `delete, modify, suspend, resume, reconcile`; **эдж — `delete, modify, reconcile` (suspend отсутствует)**.
- `dependencies`/`dependentInstances`: у кластера и эджа пусто; у `fullpipe-vdc` в зависимых три инстанса
`fullpipe-edge` (`b289beb8…`, `86a01033…`, `2c37fed1…`) — рабочий только `2c37fed1…`, два других сироты
от прошлых прогонов. Кластера в зависимых нет → платформа не блокирует удаление эджа/квоты при живом кластере.
- vDC (услуга 21) по инструкции удаляется только через 14 дней после `suspend`; при живых Edge/vApp/VM/кластере
удаление — через поддержку.
---
## 4. `adopt_existing_on_create` — как усыновление реально работает
- Кластера не было в state, а ресурс есть в `shturval.tf` → план показывал `will be created`. Это **не**
доказательство отсутствия adopt: проверка/adopt выполняются в `Create` на apply
(`provider/internal/resources_gen/150_k8s_sthutrval_cluster_resource.go:211`).
- Дефолт adopt — `false` (там же, строка 136). При существующем инстансе:
`running` либо `suspended` без adopt → hard error «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (…RUNNING/SUSPEND)»
(`provider/internal/resources_core/resource_diagnostics_required.go:241-259`). Дубль при этом не создаётся.
- Исправление: добавлен `adopt_existing_on_create = true` в `DEV_STAND/FullPipe/shturval.tf`
(коммит `57abb7b`; бэкап `TMP/backup_2026-09-24/shturval.tf.before-adopt`).
- Результат apply (проверено): state получил `id=94627ff4-…`; провайдер сам выполнил `resume`
(18:26:30, success) — инстанс `running`, `isSuspended=false`; кластер жив (2 ноды Ready);
`nubes_vc_nsxt_snat.snat` в state (`internet-ipv4-v1`), live эдж `ipSpaceName=internet-ipv4-v1`
(modify 18:23:12, success). План после apply: действий по ресурсам нет, только дрейф state
(`edge.state_params.ipSpaceName`: `no-needed` → `internet-ipv4-v1`) и `Changes to Outputs`.
---
## 5. Дизайн «freeze on destroy» (решение)
**Требование заказчика:** пользователь стенда не должен ничего делать руками и не должен звать DevOps.
`destroy` не удаляет, а «замораживает»: кластер → `suspend`, vDC → `suspend`, эдж → оставить как есть,
SNAT → не выключать, квота IP → не трогать. Следующий `apply` возвращает всё в работу.
**Что уже есть в ядре:**
- `resources_core/crud.go:64-96` — `DeleteResourceWithTimeout(..., destroyBehavior, ...)`, режимы
`state_only`/`detach` (ничего не делаем, ресурс забывается) и `suspend` (шлём операцию `suspend`).
- `crud.go:162-214` — adopt на create: для `StateSuspended` при `resumeIfExists` шлёт `resume` и ждёт готовности.
- `nubes_k8s_sthutrval_cluster` и `nubes_vc_vdc` — `suspend_on_destroy` (default `true`) + `adopt_existing_on_create`.
- `nubes_vc_nsxt_snat` и `nubes_vc_org_ip_allocation` — `keep_on_destroy` (в стенде `false`).
- `nubes_vc_nsxt` (эдж) — только `adopt_existing_on_create`; в `provider/resources_yaml/22_vc_nsxt.yaml`
нет операции `suspend` → генератор ставит `deleteMode := "delete"`
(`TOOLS/resource-generator/internal/templates/instance.go:546-552`), т.е. эдж удаляется по-настоящему.
**Решение:** три режима в одной общей логике — `delete` (дефолт), `suspend` (где сервис умеет),
`keep` → `state_only` (эдж, SNAT, IP-квота). Дефолты провайдера остаются разрушающими; freeze включается
явно в `.tf` стенда. Обязательны предупреждения в выводе destroy («заморожено (suspend)», «оставлен как есть:
эдж», «квота IP не изменена») — иначе freeze выглядит как успешное удаление.
**Реализация — только через генератор (ручные правки `resources_gen/` затрёт регенерация):**
1. `TOOLS/resource-generator/internal/types/types.go` — в `LifecycleSpec` добавить
`KeepOnDestroyDefault *bool \`yaml:"keep_on_destroy_default"\``, в `GenResource` — `KeepOnDestroy bool`.
2. `TOOLS/resource-generator/internal/loader/loader.go` — читать новый ключ (дефолт `false`),
как сейчас читается `suspend_on_destroy_default` (строки ~114-118).
3. `TOOLS/resource-generator/internal/templates/instance.go` — эмитить атрибут `keep_on_destroy`
(Optional+Computed, дефолт из YAML) в schema и модель; в `Delete` собирать режим:
`suspend` → `state_only` (keep) → `delete`.
4. `provider/resources_yaml/22_vc_nsxt.yaml` — `keep_on_destroy_default: false`.
5. Регенерация + проверка воспроизводимости (`TOOLS/scripts/10_yaml_stability_run.sh`) → сборка/релиз.
`resources_core`-ресурсы (SNAT, квота IP) менять не нужно — флаг там уже есть.
**Конфиг стенда для freeze:**
| Файл / ресурс | Сейчас | Надо |
|---|---|---|
| `modifiers.tf` → `nubes_vc_org_ip_allocation.org_ip` | `keep_on_destroy = false` (:39) | `true` |
| `modifiers.tf` → `nubes_vc_nsxt_snat.snat` | `keep_on_destroy = false` (:29) | `true` |
| `edge.tf` → `nubes_vc_nsxt.edge` | атрибутов нет | `keep_on_destroy = true` + `adopt_existing_on_create = true` |
| `shturval.tf` → `nubes_k8s_sthutrval_cluster.shturval` | `adopt=true`, `suspend_on_destroy` дефолт | `adopt=true` (есть) + `suspend_on_destroy = true` явно |
| `vdc.tf` → `nubes_vc_vdc.vdc` | `suspend_on_destroy = true`, `adopt = true` (:17,19) | без изменений |
**Что будет при destroy в режиме freeze:** из state ресурсы уйдут, но в облаке ничего не изменится —
SNAT останется включённым (Delete при `keep=true` печатает «SNAT не выключался», `nsxt_snat_resource.go:185-190`),
квота IP — `count=3`, эдж — running, vDC и кластер — suspended. При следующем `apply` ресурсы создадутся заново
и усыновят живые объекты (`suspend` → `resume`, `running` → просто UID), SNAT/квота отправят те же значения → no-op.
**Полный teardown** — только явный opt-out (`keep_on_destroy=false` / `suspend_on_destroy=false`) и в порядке:
кластер → `count=0` → SNAT → эдж → vDC. Иначе `count` ниже занятых не опустить, а удаление эджа оставит кластер
без внешнего API/ingress.
---
## 5.1. Реализовано (вечер 24.09)
**Генератор (коммит `22c6c83`):**
- `TOOLS/resource-generator/internal/types/types.go` — в `ServiceSpec.Lifecycle` добавлен `KeepOnDestroyDefault *bool`
(`yaml:"keep_on_destroy_default"`), в `GenResource` — `KeepOnDestroy bool`.
- `TOOLS/resource-generator/internal/loader/loader.go` — читает ключ из YAML (дефолт `false`) и передаёт в генератор.
- `TOOLS/resource-generator/internal/templates/instance.go` — атрибут `keep_on_destroy` (Optional+Computed, дефолт из YAML)
во всех instance-ресурсах; в `Delete` режим выбирается так: `keep_on_destroy` → `state_only` (приоритет),
иначе `suspend_on_destroy` (где сервис умеет) → `suspend`, иначе `delete`; после успешного удаления печатаются
предупреждения «Ресурс заморожен, а не удалён» / «Ресурс оставлен как есть, а не удалён».
- Флаг получили **все 40 instance-ресурсов** (проверено: `grep -l keep_on_destroy generated/dev/go/*_resource.go`).
Subresource-ресурсы (пользователи/БД/бэкапы) — без него: другой шаблон, у них нет своего suspend.
**YAML-спеки не правим:** `01_generate_yamls.sh` перезаписывает `generated/<stand>/resources_yaml/*.yaml` из API,
поэтому ручной ключ там не живёт. Ключ `keep_on_destroy_default` поддержан, но не используется:
дефолт `false` берётся из нулевого значения Go.
**Проверка:**
- `02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev` → `dev-materialize.sh dev` →
`go build ./...` в `provider/` — OK, `go test ./...` — OK.
- Локальная проверка конфига без релиза: собран свой бинарь в `TMP/devbin/`, `dev_overrides` —
`TMP/terraformrc.dev`; `TF_CLI_CONFIG_FILE=TMP/terraformrc.dev terraform validate` — Success,
`terraform plan` — `0 to add, 5 to change, 0 to destroy`, у ресурсов меняется только новый
атрибут (`keep_on_destroy = false -> true` у квоты, `+ keep_on_destroy = false` у vDC/кластера/эджа/SNAT) плюс
пересчёт outputs.
**Конфиг стенда (коммит `40aef87`):** `modifiers.tf` — `keep_on_destroy = true` у квоты IP (`:31`) и SNAT (`:42`);
`edge.tf` — `keep_on_destroy = true` (`:23`) + `adopt_existing_on_create = true` (`:27`);
`shturval.tf` — явные `adopt_existing_on_create = true` (`:113`) и `suspend_on_destroy = true` (`:117`);
у vDC в `vdc.tf:17,19` оба флага уже были.
**Релиз выполнен:** `03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.22` → три платформы (linux/darwin/windows amd64) + `SHA256SUMS`/`.sig` залиты, версия видна в реестре (проверено `GET /v1/providers/nubes-dev/nubes/versions` → `2.0.22`); `VERSIONS.md` обновлён (коммит `c29df21`).
**Что осталось:** проверить цикл на живом стенде: `destroy` = заморозка (кластер/vDC → suspend, эдж/SNAT/квота IP → state_only с предупреждениями) и `apply` = разморозка (adopt + resume). `apply`/`destroy` запускает только пользователь.
**Состояние на 19:4x:** пин в `DEV_STAND/FullPipe/versions.tf` поднят до `2.0.22`, `terraform plan` → «No changes» (дрейф по `edge.state_params.ipSpaceName` ушёл после apply SNAT). Флаги в state: кластер — `adopt=true`, `suspend_on_destroy=true`, `keep=false`; vDC — то же; эдж — `keep=true`, `adopt=true`; SNAT — `keep=true`; квота IP — `keep=true`. Кластер жив: 2 ноды Ready, подов 49 (готовых 44 — те же 4 мусорных пода init-job).
---
## 5.2. Проверено вживую: `destroy` = заморозка (24.09, вечер)
`terraform destroy` на `DEV_STAND/FullPipe` (провайдер `2.0.22`) — «Apply complete! Resources: 0 added, 0 changed, 5 destroyed», ошибок нет. Предупреждения вывода:
- `SNAT не выключался` — `keep_on_destroy = true`: `ipSpaceName` шлюза оставлен без изменений (NSXT-логика, `nsxt_snat_resource.go`);
- `Ресурс оставлен как есть, а не удалён` — эдж (`vc_nsxt`, service_id=22) не менялся в облаке;
- `Аллокация IP не снималась` — квота внешних IP организации оставлена без изменений;
- `Ресурс заморожен, а не удалён` (×2) — vDC (`vc_vdc`, 21) и кластер (`k8s_sthutrval_cluster`, 150) переведены в `suspend`.
Состояние после destroy (проверено kubectl + API ЛК):
| Объект | Статус |
|---|---|
| `terraform state list` | пусто (все 5 ресурсов убраны из стейта) |
| Кластер `94627ff4…` | `suspended`, `isSuspended=true`, не удалён |
| vDC `d0937335…` | `suspended`, `isSuspended=true`, не удалён |
| Эдж `2c37fed1…` | `running`, `ipSpaceName=internet-ipv4-v1` (SNAT включён) |
| Орга `57eeacd1…` | `running`, `vIPConfigure=[{name:internet-ipv4-v1,count:3}]` (квота не тронута) |
| Кластерный API `.146:6443` | TCP принимается эджем, но k8s не отвечает (`connection reset by peer`) — ВМ кластера спят |
| Ingress `.148:443` | открыт (эдж/AVI живут) |
Осталось проверить обратный ход: `terraform apply` должен усыновить те же инстансы (`adopt_existing_on_create=true`)
и разморозить их (`resume`) — запускает пользователь.
---
## 5.3. Баг: регистр UUID внутри JSON (первый `apply` после заморозки)
**Симптом.** `apply` после destroy (провайдер `2.0.22`) упал:
`Error: Ошибка клиента … required params mismatch for resource_name shturval-dev: startupConfiguration
(plan={…"nsxtUid":"2c37fed1-…"}, actual={…"nsxtUid":"2C37FED1-…"})`.
Эдж после пересоздания вернул UUID в lowercase, а в живом инстансе кластера тот же UUID лежит в UPPERCASE.
**Почему вылезло именно сейчас.** Регистр ранее учли в пяти местах — `core/refsvc.go:20` (lowercase при отправке),
`core/refsvc_resolve.go:28-29`, `resources_core/params_compare.go` (`normalizeCompareValue` — одиночные значения),
шаблон `instance.go:204` (`strings.EqualFold` для create-only), плюс восстановление регистра в state.
Ни одно из них не смотрит **внутрь JSON**, а adopt **приостановленного** инстанса сравнивает параметр целиком как JSON
(`RequiredParamsMismatch` → `paramsEquivalent` → `JSONStringsEquivalent` → `normalizeJSONScalarsToStrings`,
где было `case string: return val`). У Штурвала ref-параметры упакованы в JSON (`startupConfiguration`),
а путь adopt-suspended задействован впервые.
**Аудит: где ещё может вылезти.**
| # | Место | Что ломает |
|---|---|---|
| 1 | `resources_core/required_params_compare.go:94` | adopt suspended — hard error (сегодняшний кейс) |
| 2 | `core/modifier_compare.go:47,53` | ложное «не равно» → лишний `modify` при каждом apply (сейчас спит: у `org_ip_allocation` UUID внутри `vip_configure` нет) |
| 3 | `resources_core/state_refresh.go:150` | сохранение планового JSON при эквивалентности → в стейт уедет регистр API |
| 4 | `resources_core/resource_diagnostics_required.go:104` | та же `RequiredParamsMismatch` в create-диагностике |
| 5 | `resources_core/params_compare.go` (`ParamsMatchForResume`) | одиночный UUID ок, JSON — та же дыра (в сгенерированном коде не вызывается) |
| 6 | `resources_core/json_planmodifier.go` (`JsonNormalize`) | только `json.Compact` → для user-facing JSON-атрибутов с UUID риск вечного diff |
| 7 | `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`) | ref-параметр, зашитый внутрь JSON, не проверяется вовсе → чужой инстанс не отловится (открыто) |
| 8 | `core/operation_run.go:151`, `operation_run_bycode.go:125` (`lookupLiveParam`) | подстановка live-значений по ключам; при другом регистре ключа молча не сработает (надо проверить, открыто) |
**Фикс (коммит — см. ниже).**
- `internal/core/jsonutil/jsonutil.go`: добавлен `LowercaseUUIDsInText` (regex по UUID-подстроке) и строковые значения
внутри JSON теперь нормализуются (`normalizeJSONScalarsToStrings`, `case string`) — закрывает пункты 1–5 сразу.
- `internal/resources_core/json_planmodifier.go`: `JsonNormalize()` после `json.Compact` приводит UUID-подстроки
к lowercase (типы и порядок ключей НЕ меняются) — закрывает пункт 6.
- Тесты: `internal/core/jsonutil/jsonutil_test.go` (UUID внутри вложенного JSON, регистр, разные UUID, числа/bool,
текст без UUID), `internal/resources_core/params_compare_test.go` (`paramsEquivalent` на реальном `startupConfiguration`).
**Открыто (7–8):** валидация ref-параметров внутри JSON и регистр ключей в `lookupLiveParam` — отдельная задача
(требует решения, что делать при mismatch, и живой проверки).
**Релиз:** `2.0.23` собран и залит в dev-реестр (`03_build_and_upload_provider.sh`), версия видна в реестре;
`VERSIONS.md` обновлён. После него нужно повторить `apply` на стенде (усыновление + `resume`).
---
## 6. Мои ошибки в этой сессии (обязательно к фиксации)
1. Сказал, что apply «либо даст ошибку, либо создаст дубль кластера» — **неверно**: будет hard error
«РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ», дубль не создаётся (проверено в коде).
2. Интерпретировал `will be created` в плане как доказательство отсутствия adopt — adopt работает в `Create`, не в plan.
3. Перепутал колонки UI: «17/24» — это «Системные сервисы», а «Ingress» — домен-шаблон, а не счётчик.
4. Предлагал ручные обходы (`terraform state rm`, `removed`-блок, `-target`) там, где требуется автоматический
freeze флагами — пользователь это отклонил.
---
## 7. Открытые вопросы / тикет в платформу
1. Job установщика: Failed-поды живут сутки (`ttlSecondsAfterFinished: 86400`), `backoffLimit: 10`,
очистки нет; установка компонентов идёт до готовности Cilium → EPERM на webhook-ах.
2. Эдж: в `availableOperations` нет `suspend` → «заморозить» его платформенно невозможно, только «не трогать».
3. Квота IP: `count` нельзя опустить ниже занятых, штатного API «занято N» нет — только текст ошибки.
4. vDC: полное удаление только через 14 дней после `suspend`; при живых сущностях — через поддержку.
@@ -0,0 +1,45 @@
# CHAT RESUME — Штурвал dev-00: диагностика + дизайн «freeze on destroy» (2026-09-24)
> Полная версия с источниками: `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md`
## Что сделано
1. **Проверка кластера из локали** (контекст `tazet@narod.ru@shturval-dev-00`, API `185.247.187.146:6443`):
всё зелёное — 2 ноды Ready, `shturvalserviceconfigs` 41/41 `ready`, `nodeconfigitems` 4/4,
у всех 35 сервисов есть endpoints. Остался только «мусор»: 4 подвисших пода `kube-system/shturval-init-job`
(3 Error + 1 Unknown) — Job уже `Complete 1/1`, поды уйдут сами по `ttlSecondsAfterFinished: 86400`
(~25.09 14:31 UTC). Причина падений — webhook-вызовы до готовности Cilium: `connect: operation not permitted`.
2. **Расшифрованы счётчики ЛК**: `Pods 44/48` = готовые/всего (48 = поды без Completed); «Системные сервисы»
= число сервисов в режиме `auto` (17/24 → 24/24 после установки); «Ingress» = домен, не счётчик;
⚠️ на «Pods» = те 4 подвисших пода.
3. **Разобрана ошибка destroy**: `nubes_vc_org_ip_allocation` шлёт `count=0`, платформа не даёт опустить
`count` ниже занятых (2 адреса держит кластер: `.146` API и `.148` ingress; `suspend` адреса не освобождает).
Destroy прервался на аллокации, SNAT успел сняться → «рваное» состояние.
4. **Adopt починен**: добавлен `adopt_existing_on_create = true` в `DEV_STAND/FullPipe/shturval.tf`
(коммит `57abb7b`). Apply усыновил существующий инстанс `94627ff4-…` и сам сделал `resume` (18:26:30) —
кластер снова running, SNAT восстановлен (`internet-ipv4-v1`, modify 18:23:12).
5. **Решение по дизайну** (Опус + наше): три режима destroy в одной логике — `delete` (дефолт),
`suspend` (где сервис умеет), `keep` → `state_only` (эдж, SNAT, квота IP). Реализация — **через генератор**
(`TOOLS/resource-generator`: types/loader/templates + `keep_on_destroy_default` в YAML), не ручными правками
`resources_gen/`. Дефолты провайдера остаются разрушающими; freeze включается явно в `.tf` стенда.
## Что осталось сделать (по команде)
1. Правка генератора: `keep_on_destroy` для инстанс-ресурсов (эдж в первую очередь) + предупреждения в `Delete`.
2. `DEV_STAND/FullPipe`: `keep_on_destroy = true` в `modifiers.tf` (:29 snat, :39 квота) и в `edge.tf`
(+ `adopt_existing_on_create = true`), кластеру — явный `suspend_on_destroy = true`.
3. Регенерация + проверка воспроизводимости `10_yaml_stability_run.sh`, сборка/релиз провайдера.
4. Тикет в платформу: TTL/очистка Failed-подов установщика, отсутствие `suspend` у эджа, `count` ниже занятых.
## Полезное для воспроизведения
```bash
# состояние кластера
kubectl get nodes; kubectl get pods -A | grep -v -E "Running|Completed"
kubectl -n kube-system get job shturval-init-job -o json | jq '.spec.backoffLimit,.spec.ttlSecondsAfterFinished,.status'
# API ЛК dev (нужны User-Agent и Referer, иначе 403)
TOK=$(tr -d '\n' < secrets/narodDEV.token)
curl -s -H "Authorization: Bearer $TOK" -H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
-H "Referer: https://deck-dev.ngcloud.ru/" \
'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/94627ff4-33a5-48f2-aca1-695741e0b6a2'
```
@@ -0,0 +1,142 @@
# CHAT RESUME — Штурвал + «freeze on destroy»: состояние на 2026-09-25
> Цель файла: начать новый чат **без уточняющих вопросов** — здесь всё, что сделано, где живёт
> документация, что в каком состоянии и что делать дальше.
## 0. Где что лежит (точки входа)
| Что | Путь |
|---|---|
| Полный разбор сессии (диагностика, пайплайн, аудит регистра UUID) | `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md` |
| Страница для пользователя/DevOps: поведение и отличия от канонического Terraform | `docs/30_registry/guides/provider-behavior.md` |
| Регистр UUID (все нерабочие подходы + кейс UUID внутри JSON, §10) | `docs/60_strategy/terraform_case_sensitivity_fix.md` |
| Запись дня с коммитами и результатами | `HISTORY/2026-09-24_shturval_dev00_adopt_and_freeze_design.md` |
| Память репозитория (факты, шпаргалки, уроки) | `/memories/repo/shturval-destroy-freeze.md` |
| Версии провайдеров по стендам | `VERSIONS.md` |
Репозиторий: `/home/naeel/TF/tf_provider`, ветка `master`, remote `origin`
(`https://gitea.services.ngcloud.ru/terraform/tf_provider.git`). Снапшот состояния — ветка
`snapshot/2026-09-25-shturval-freeze-state`.
## 1. Что уже сделано
1. **Диагностика стенда `shturval-dev-00`** (услуга 150, инстанс `shturval-dev`, uid `94627ff4-…`):
кластер здоров (2 ноды Ready, 41/41 Shturval-сервисов `ready`, NodeConfigItems 4/4, у всех сервисов
есть endpoints). Единственный мусор — 4 подвисших пода `kube-system/shturval-init-job`
(3 Error + 1 Unknown при `Complete 1/1`), причина — webhook-и Штурвала недоступны до готовности Cilium
(`connect: operation not permitted`); самоочистка по `ttlSecondsAfterFinished: 86400` (≈25.09 14:31 UTC).
2. **Расшифрованы счётчики ЛК/Штурвала**: `Pods X/Y` = готовые/всего (без `Completed`);
«Системные сервисы» = число сервисов в режиме `auto`; «Конфигурация узлов» = NodeConfigItems;
«Ingress» = домен-шаблон, не счётчик.
3. **Разобран провал `destroy`**: `nubes_vc_org_ip_allocation` отправлял `count=0`, платформа не даёт
опустить `count` ниже занятых адресов — их держит кластер (`.146` API и `.148` ingress), `suspend`
адреса не освобождает.
4. **Усыновление исправлено**: в `DEV_STAND/FullPipe/shturval.tf` добавлен `adopt_existing_on_create = true`
(коммит `57abb7b`); проверено вживую — apply усыновил существующий инстанс и сам сделал `resume`.
5. **Реализован третий режим destroy** (коммит `22c6c83`, генератор, универсально для всех instance-ресурсов):
`keep_on_destroy` → `state_only` (приоритет), иначе `suspend_on_destroy` → `suspend`, иначе `delete`;
в `Delete` добавлены предупреждения «Ресурс заморожен, а не удалён» / «оставлен как есть».
6. **Конфиг стенда переведён в режим «заморозки»** (коммит `40aef87`): `keep_on_destroy = true` у квоты IP,
SNAT и эджа; `adopt_existing_on_create = true` и `suspend_on_destroy = true` у кластера; у vDC оба флага
уже стояли.
7. **Проверен цикл `destroy` = заморозка** на живом стенде (`2.0.22`): `0 added, 0 changed, 5 destroyed`,
ошибок нет; кластер и vDC → `suspended`, эдж `running` с `ipSpaceName=internet-ipv4-v1`, квота IP `count=3`,
state пуст; в выводе — 5 предупреждений.
8. **Найден и исправлен баг регистра UUID внутри JSON** (коммит `621280a`, релиз `2.0.23`): первый `apply`
после заморозки падал на `required params mismatch … startupConfiguration` (`2c37fed1-…` в плане против
`2C37FED1-…` в живом инстансе). Проведён аудит 8 мест (см. §10 в `terraform_case_sensitivity_fix.md`),
добавлены `jsonutil.LowercaseUUIDsInText` и нормализация строк внутри JSON, `JsonNormalize()` приводит
UUID-подстроки к lowercase; покрыто тестами (`jsonutil_test.go`, `params_compare_test.go`), `go test ./...` зелёный.
9. **Документация**: страница `docs/30_registry/guides/provider-behavior.md`, обновлённый §10 в
`terraform_case_sensitivity_fix.md`, записи в `NOTES`/`HISTORY`, память репозитория.
## 2. Текущее состояние (на момент записи)
- `DEV_STAND/FullPipe`: **`terraform state list` пуст** (после проверочного `destroy`).
- В облаке: кластер `shturval-dev-00` — `suspended`; vDC — `suspended`; эдж — `running`
(`ipSpaceName = internet-ipv4-v1`, SNAT включён); квота IP организации — `count = 3`.
- Кластер «спит»: `.146:6443` TCP принимается эджем, но k8s не отвечает (`connection reset by peer`);
`.148:443` открыт (эдж/AVI живут).
- Версия провайдера: в реестре dev — **`2.0.23`**; пины `versions.tf` в `DEV_STAND/FullPipe` и
`DEV_STAND/FPipeGmail` — `2.0.23`.
- Новый (не проверенный вживую) стенд пользователя: `DEV_STAND/FPipeGmail/`.
## 3. Что делать дальше
1. **Проверить обратный ход** (запускает только пользователь): `terraform apply` в `DEV_STAND/FullPipe`.
Ожидание: `adopt` по имени + `resume` для кластера и vDC; эдж/SNAT/квота — no-op; затем `plan` = `No changes`.
Проверки: `terraform state list`, `terraform state show`, статусы инстансов в API ЛК, `kubectl get nodes`
(снова Ready), поды `44/48` (+ мусор `shturval-init-job`, уйдёт сам).
2. **Доку при необходимости**: добавить страницу `provider-behavior.md` в `nav` (`mkdocs.yml`) и запустить
`TOOLS/scripts/04_build_and_publish_docs.sh` — **не запускать без прямой команды**.
3. **Открытые техдолги:**
- ref-параметр внутри JSON **не валидируется** при adopt (`resources_core/ref_validation.go`);
- регистр ключей в `lookupLiveParam` (`core/operation_run.go`, `operation_run_bycode.go`) — требует живой проверки;
- тикет в платформу: TTL/очистка Failed-подов установщика + порядок установки компонентов до готовности Cilium;
- тикет в платформу: у Эджа нет операции `suspend` (в `availableOperations` только `delete/modify/reconcile`).
4. **Полный teardown** — осознанно: `keep_on_destroy = false` и `suspend_on_destroy = false`, порядок
кластер → `count=0` → SNAT → эдж → vDC (для vDC действует правило «14 дней после suspend»).
## 4. Шпаргалка: режимы destroy
| Ресурс | Флаг | Поведение при destroy | Предупреждение |
|---|---|---|---|
| `nubes_k8s_sthutrval_cluster` | `suspend_on_destroy = true` | `suspend` | «Ресурс заморожен, а не удалён» |
| `nubes_vc_vdc` | `suspend_on_destroy = true` | `suspend` | то же |
| `nubes_vc_nsxt` (эдж) | `keep_on_destroy = true` | не трогается | «Ресурс оставлен как есть, а не удалён» |
| `nubes_vc_nsxt_snat` | `keep_on_destroy = true` | не трогается | «SNAT не выключался» |
| `nubes_vc_org_ip_allocation` | `keep_on_destroy = true` | не трогается | «Аллокация IP не снималась» |
Приоритет: `keep_on_destroy` > `suspend_on_destroy` > обычное удаление. Дефолты провайдера — разрушающие;
«заморозка» включается в `.tf` стенда.
## 5. Факты платформы (проверено)
- 1 кластер Штурвала = 1 vDC; vDC удаляется только через 14 дней после `suspend`.
- Квоту IP нельзя опустить ниже занятых адресов; адреса кластера `suspend` не освобождает.
- У эджа нет `suspend`; удаление эджа при живых зависимых (vApp/VM/кластер) недопустимо.
- API ЛК: `GET /api/v1/svc/instances/{uid}`, состояние — `instance.state.params`, статус — `explainedStatus`,
операции — `availableOperations`; ошибки операции — в теле (`isSuccessful=false`, `errorLog`), HTTP 200/201.
- Обязательные заголовки API ЛК: браузерный `User-Agent`, `Referer: https://deck-dev.ngcloud.ru/`,
`Authorization: Bearer <secrets/narodDEV.token>` — иначе `403`.
- Регистр UUID: облако отдаёт один и тот же UUID в разных регистрах → сравнивать всегда без учёта регистра
(в т.ч. **внутри JSON**).
## 6. Релизы
- Схема: prod `1.*`, dev `2.*`, test `3.*`; актуальная dev — `2.0.23` (`VERSIONS.md`).
- Релиз: `TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev <версия>`
(сам прогоняет `01` + `02`, собирает, подписывает, заливает, обновить `VERSIONS.md` вручную).
- Локальная проверка без релиза: `go build -o TMP/devbin/terraform-provider-nubes .` в `provider/`
и `TF_CLI_CONFIG_FILE=TMP/terraformrc.dev terraform validate|plan` (dev_overrides).
- YAML-спеки (`generated/*/resources_yaml/`) **не редактировать руками** — `01_generate_yamls.sh` перезапишет их из API.
## 7. Правила работы (для нового чата)
- `terraform apply` / `destroy` — только пользователь. Мне доступны `init/plan/validate/show/state show`.
- Никаких правок, коммитов, релизов и публикаций без прямой команды; после каждой правки — коммит.
- Перед правками важных файлов — бэкап в `TMP/backup_<дата>/`.
- Не «улучшать» соседние стенды/сервисы без команды (scope creep запрещён).
## 8. Быстрые команды
```bash
# состояние стенда
cd DEV_STAND/FullPipe && terraform state list && terraform plan
terraform state show nubes_k8s_sthutrval_cluster.shturval | grep -E "id|adopt|suspend|keep"
# кластер
kubectl get nodes
kubectl get pods -A --no-headers | awk '{split($3,a,"/"); tot++; if($4=="Running"&&a[1]==a[2]) ok++} END{print tot, ok}'
kubectl get pods -A | grep -v -E "Running|Completed"
# API ЛК (dev)
TOK=$(tr -d '\n' < secrets/narodDEV.token)
curl -s -H "Authorization: Bearer $TOK" \
-H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
-H "Referer: https://deck-dev.ngcloud.ru/" \
'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/94627ff4-33a5-48f2-aca1-695741e0b6a2'
# реестр провайдера (dev)
curl -s https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/nubes-dev/nubes/versions
```
@@ -51,8 +51,13 @@ k8sShturval -> create
## 3. Подтверждённые факты (с источниками) ## 3. Подтверждённые факты (с источниками)
1. **Схема tf-ресурса строится ТОЛЬКО из `create`** (генератор `TOOLS/resource-generator`). 1. ⛔ **ИСПРАВЛЕНО 2026-09-24. Прежняя формулировка «схема строится ТОЛЬКО из `create`» — НЕВЕРНА.**
→ modify-only параметры в схему не попадают. Генератор **мержит** create+modify: `TOOLS/resource-generator/internal/loader/loader.go:96` →
`schemaParams := params.Merge(createParams, modifyParams)`; коммит `261809b` (2026-09-22)
«is_modifiable=true → параметр НЕ create-only».
Следствие: modify-параметры **уже в схемах** и применяются в `Update` —
`nubes_vc_org.v_ip_configure` (шлёт `662`), `nubes_vc_nsxt.ip_space_name` (шлёт `372`).
Разбор и live-факты: `NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md`.
2. **`vIPConfigure`** (vc_org, modify id **207**, param id **662**, `array-map-fixed`, sub: `name`=39, `count`=40) 2. **`vIPConfigure`** (vc_org, modify id **207**, param id **662**, `array-map-fixed`, sub: `name`=39, `count`=40)
есть **только** в modify. В `create` (id 136) — только `resourceRealm`(418), `organizationType`(556), `orgSuffix`(1125). есть **только** в modify. В `create` (id 136) — только `resourceRealm`(418), `organizationType`(556), `orgSuffix`(1125).
Файл: `generated/dev/resources_yaml/19_vc_org.yaml`. Файл: `generated/dev/resources_yaml/19_vc_org.yaml`.
@@ -141,6 +146,11 @@ k8sShturval -> create
«Update = no-op». **Файл перезаписан** актуальными фактами. НЕ использовать старую формулировку. «Update = no-op». **Файл перезаписан** актуальными фактами. НЕ использовать старую формулировку.
3. **Старые «модификаторы» были написаны и даже работали** (09-22), но заход признан негодным: 3. **Старые «модификаторы» были написаны и даже работали** (09-22), но заход признан негодным:
доменную логику вшили в универсальный генератор (метки в YAML). Соответствующие документы помечены баннером LEGACY. доменную логику вшили в универсальный генератор (метки в YAML). Соответствующие документы помечены баннером LEGACY.
4. **Ложный «факт» §3.1 («схема только из `create`»).** Проверено в коде 2026-09-24: генератор мержит
create+modify (`loader.go:96`), поэтому `v_ip_configure` и `ip_space_name` **уже есть** в схемах
`nubes_vc_org` / `nubes_vc_nsxt` и работают через `Update`. Вывод «прописать поле в .tf → падает на plan»
относится максимум к провайдеру, собранному до коммита `261809b` (2026-09-22). Детали —
`NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md`.
--- ---
@@ -151,6 +161,9 @@ k8sShturval -> create
- `NOTES/30_analysis/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md` — анализ, варианты A–E, мнение - `NOTES/30_analysis/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md` — анализ, варианты A–E, мнение
- `NOTES/30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md` — ответ Opus + поправки (ложные блокеры сняты) - `NOTES/30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md` — ответ Opus + поправки (ложные блокеры сняты)
- `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md` — проверенные факты по vIPConfigure - `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md` — проверенные факты по vIPConfigure
- `NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md` — разбор create орги/эджа + свежий `state.params` (`vIPConfigure: [{}]`, отсутствие `ipSpaceName`)
- `NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md` — правки/ограничения (часть опровергнута тестом; раньше в карте отсутствовал)
- `HAR/globak.har`, `HAR/org_already exists.har` — записи ЛК от 2026-09-24
- `NOTES/20_prompts/prompt_for_opus_iac_shturval_modify.md` — промпт (факты исправлены) - `NOTES/20_prompts/prompt_for_opus_iac_shturval_modify.md` — промпт (факты исправлены)
- `generated/dev/resources_yaml/19_vc_org.yaml`, `22_vc_nsxt.yaml` — спеки (факты по операциям/параметрам) - `generated/dev/resources_yaml/19_vc_org.yaml`, `22_vc_nsxt.yaml` — спеки (факты по операциям/параметрам)
- `HAR/org_enough_.har`, `HAR/org2.har`, `HAR/edge_.har` — live-семантика modify - `HAR/org_enough_.har`, `HAR/org2.har`, `HAR/edge_.har` — live-семантика modify
@@ -0,0 +1,200 @@
name: vc_nsxt
service_id: 22
service_display_name: Сетевой шлюз периметра (Edge)
service_short_name: vc_nsxt
service_man: '# Инструкция по развертыванию Сетевой шлюз периметра (Edge) через платформу<br/><br/>## 1. Общая информация<br/><br/>Сервис **Сетевой шлюз периметра (Edge)** предназначен для создания периметрового сетевого шлюза в рамках одного виртуального датацентра (vDC) или группы виртуальных датацентров (groupvDC). <br/>При создании автоматически разворачивается routed-сеть с адресным пространством `10.10.102.0/24`. <br/>Сервис обеспечивает сетевую изоляцию, маршрутизацию, а также может включать функциональность балансировщика нагрузки AVI (ALB) для последующей интеграции, включая поддержку кластеров Штурвал.<br/><br/>### Доступные операции<br/><br/>**create** — Создание нового Edge с привязкой к vDC или groupvDC и развёртыванием routed-сети. <br/>**delete** — Удаление ранее созданного Edge. Недоступно при наличии зависимых услуг (vApp, VM, Кластер Штурвал). <br/>**modify** — Изменение параметров и сетевых настроек существующего Edge.<br/><br/>---<br/><br/>## 2. Параметры развертывания<br/><br/>Ниже приведены параметры операции **create**.<br/><br/>### Тип родительской услуги<br/>Определяет контекст размещения Edge. <br/>Допустимые значения: `vdc`, `groupvdc`. <br/>Использование:<br/>- При выборе `vdc` обязателен параметр **UUID VDC**.<br/>- При выборе `groupvdc` обязателен параметр **UUID Группы VDC**.<br/><br/>### UUID VDC<br/>Идентификатор виртуального датацентра. <br/>Необходимо предварительно создать vDC через услугу «Виртуальный датацентр (vDC)». <br/>Указывается только при выборе родительского типа `vdc`.<br/><br/>### UUID Группы VDC<br/>Идентификатор группы виртуальных датацентров. <br/>Создаётся через услугу «Группа виртуальных датацентров (groupvDC)». <br/>Используется при выборе родительского типа `groupvdc`.<br/><br/>### Включить ALB<br/>Флаг активации AVI Load Balancer в Cloud Director. <br/>Пример значения: `true`/`false`. <br/>Нужен для развёртывания кластера Штурвал и возможности создания Virtual Services.<br/><br/>### Наименование segroup<br/>Имя сервиса групп (segroup) на уровне ресурсной платформы. <br/>Обязательно при включённом ALB. <br/>Определяет группу, в которой будут выделяться пулы AVI для Virtual Services.<br/><br/>### virtualServicesCount<br/>Количество резервируемых виртуальных сервисов (Virtual Services) на AVI. <br/>Типичное значение: 1–10 в зависимости от нагрузки.<br/><br/>---<br/><br/>## 3. Рекомендованные характеристики<br/><br/>### Тестовое окружение (Test)<br/><br/>- Тип родительской услуги: `vdc` <br/>- virtualServicesCount: 1–2 <br/>- ALB: выключен по умолчанию (включать только при необходимости тестирования Штурвала) <br/>- segroup: задаётся только при включённом ALB <br/>- VDC гарантии CPU/RAM: минимальные (поскольку изменить их после создания нельзя)<br/><br/>### Промышленное окружение (Production)<br/><br/>- Тип родительской услуги: `groupvdc` при работе с распределёнными нагрузками или `vdc` для локализованных проектов <br/>- virtualServicesCount: 3–10, исходя из предполагаемого количества публикаций <br/>- ALB: включён, если требуется высокая доступность сервисов или используется кластер Штурвал <br/>- segroup: рекомендуется использовать выделенную группу под проект <br/>- VDC гарантии CPU/RAM: повышенные, с учётом того, что изменить их после создания невозможно без пересоздания vDC<br/><br/>---<br/><br/>## 4. Выходные параметры<br/><br/>### Имя Edge<br/>Уникальное название созданного сетевого шлюза, используется для ссылок и дальнейших операций.<br/><br/>### Имя routed-сети<br/>Автоматически созданная routed-сеть с подсетью `10.10.102.0/24`, используется для подключения ресурсов.<br/><br/>### Параметры Edge<br/>Набор параметров, указанных пользователем при создании: тип услуги, ALB, segroup, virtualServicesCount и др. <br/>Используются в операциях modify и для анализа состояния сервиса.<br/><br/>---<br/><br/>## 5. Дополнительная информация<br/><br/>- В рамках сервиса **один раз при создании задаётся гарантированная доля CPU/RAM vDC**. Изменить её невозможно — требуется пересоздание vDC. <br/>- Удаление Edge недоступно при наличии зависимых ресурсов (vApp, VM, кластер Штурвал). <br/>- При использовании ALB необходимо корректно указывать segroup, чтобы обеспечить корректное выделение пулов AVI. <br/>- При выборе режима `groupvdc` следует учитывать распределение нагрузки между несколькими vDC. <br/>- SNAT для всех ресурсов внутри Edge можно активировать через операцию modify (параметры выделения VIP и ipSpace).<br/><br/>'
lifecycle:
suspend_on_destroy_default: false
adopt_existing_on_create_default: false
outputs:
params:
- code: state_params
type: map
- code: state_out
type: map
- code: state_params_flat
type: map
- code: state_out_flat
type: map
- code: vault_secrets
type: map
sensitive: true
- code: vault_url
type: string
- code: vault_user_path
type: string
- code: vault_fields
type: list
operations:
- name: create
id: 10
kind: instance
action: create
params:
- id: 8
code: vdcUid
data_type: string
required: false
ref_svc_id: 21
descr: UUID Услуги `Виртуальный датацентр (vDC)`
man: 'Необходимо, если выбран тип подключаемой VDC: `vdc`'
sort: 10
- id: 340
code: needEnableAVI
data_type: boolean
required: true
default: "false"
value_list:
- "false"
- "true"
descr: Включение Load Balancer
man: Параметры `Наименование segroup`, `Кол-во VS на AVI` необходимо также указать
sort: 30
is_modifiable: true
- id: 341
code: virtualServicesCount
data_type: integer &gt; 0
required: false
default: "1"
maxvalue: 4
minvalue: 1
descr: Кол-во виртуальных сервисов, которые **резервируются** на AVI
man: Во избежании коллапса пока выделяется до 4<br/>Необходимо указывать, если включён параметр `Включить ALB`
sort: 50
is_modifiable: true
- id: 621
code: vdcType
data_type: string
required: true
default: vdc
value_list:
- vdc
- vdcGroup
descr: Тип родительской услуги
man: При выборе vdc обеспечивает сетевую доступность только в рамках этого vdc<br/>При выборе groupvdc обеспечивает сетевую доступность между всеми vdc, которые включены в groupvdc
sort: 0
- id: 622
code: vdcGroupUid
data_type: string
required: false
ref_svc_id: 29
descr: UUID Услуги `Группа виртуальных датацентров (groupvDC)`
man: 'Необходимо, если выбран тип подключаемой VDC: `groupvdc`'
sort: 20
- id: 825
code: qosProfile
required: false
descr: Параметр пока не работает<br/>Должен возвращать поле из ресурсной платформы типа vc из .vcd.hardware.gatewayQoSProfiles<br/><br/>Как по идее должен работать.<br/>Нужно заполнить или CFS vdcUid, или vdcGroupUid<br/>Идеально конечно проверять `if (params.vdcType == &quot;vdc&quot; && params.vdcUid != &quot;&quot;)` или `if (params.vdcType == &quot;vdcGroup&quot; && params.vdcGroupUid != &quot;&quot;)`<br/><br/>Далее надо пойти по стейту vdc -&gt; org -&gt; resPlatform, взять gatewayQoSProfiles<br/>Или пойти по стейту vdcgroup -&gt; vdc -&gt; org -&gt; resPlatform, взять gatewayQoSProfiles<br/><br/>Регулярку могу написать (Виталя)
man: if (vdcUid != '&quot;) {<br/> наборфункций1<br/>} <br/><br/>elif (vdcGroupUid != &quot;&quot;) {<br/> наборфункций2<br/>}<br/><br/>else {<br/> return &quot;Необходимо выбрать vdc или vdcgroup&quot;<br/>}
sort: 60
depends_on: vdcUid,vdcGroupUid
is_modifiable: true
- id: 1110
code: routedNetConfiguration
data_type: map-fixed
required: true
sort: 70
is_modifiable: true
sub_params:
- id: 649
code: ipAddrPool
data_type: string
required: true
default: 10.10.102.0/24
regex: ^((25[0-4]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}0/24$
man: '**Параметр не изменяемый**<br/>Адрессный пул, которая будет присвоена routed-сети. Описывается как (10.10.10.0/24). Маска 24 обязательна. (.1) - шлюз. (.2-.254) - Под адресацию для ВМ'
is_modifiable: false
- id: 650
code: mainDns
data_type: string
required: true
default: 81.22.46.22
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- id: 651
code: secondDns
data_type: string
required: true
default: 185.247.187.77
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- name: delete
id: 25
kind: instance
action: delete
man: Невозможно удалить если routed-сеть в Edge связана с:<br/>- vApp<br/>- VM<br/>- Kubernetes-кластер Штурвал<br/><br/>Запустить операцию возможно, но будет ошибка
params: []
- name: modify
id: 111
kind: instance
action: modify
man: Для создания SNAT Правила убедитесь, что в организации есть свободные IP<br/>Редактировать кол-во свободных IP можно в услуге `Организация в Cloud Director` -&gt; `modify`
params:
- id: 368
code: needEnableAVI
data_type: boolean
required: false
value_list:
- "false"
- "true"
descr: Включение Load Balancer
man: Параметры `Наименование segroup`, `Кол-во VS на AVI` необходимо также указать
sort: 10
- id: 369
code: virtualServicesCount
data_type: integer &gt; 0
required: false
maxvalue: 4
minvalue: 1
descr: Кол-во виртуальных сервисов, которые выделяются на AVI
man: Во избежании коллапса пока выделяется до 4<br/>Необходимо указывать, если включён параметр `Включить ALB`
sort: 30
- id: 372
code: ipSpaceName
data_type: string
required: false
descr: Имя ip Space для внешнего IP
man: Необходимо указывать, если включён параметр `Выделить VIP для SNAT`
sort: 50
- id: 856
code: qosProfile
data_type: string
required: false
sort: 40
- id: 1112
code: routedNetConfiguration
data_type: map-fixed
required: true
sort: 70
sub_params:
- id: 652
code: ipAddrPool
data_type: string
required: true
default: ""
regex: ^((25[0-4]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}0/24$
man: '**Параметр не изменяемый**<br/>Адрессный пул, которая будет присвоена routed-сети. Описывается как (10.10.10.0/24). Маска 24 обязательна. (.1) - шлюз. (.2-.254) - Под адресацию для ВМ'
is_modifiable: false
- id: 653
code: mainDns
data_type: string
required: true
default: ""
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- id: 654
code: secondDns
data_type: string
required: true
default: ""
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- name: reconcile
id: 252
kind: action
action: reconcile
params: []
+20
View File
@@ -0,0 +1,20 @@
resource "nubes_vc_nsxt" "edge" {
resource_name = var.nsxt_resource_name
# Тип родительской услуги: "vdc" (нужен vdc_uid) или "vdcGroup" (нужен vdc_group_uid)
vdc_type = var.nsxt_vdc_type
# refSvc-поле: принимает UUID или имя. Здесь берём UID созданного VDC,
# чтобы Edge гарантированно создавался после vDC.
vdc_uid = nubes_vc_vdc.vdc.id
need_enable_avi = var.nsxt_need_enable_avi
virtual_services_count = var.nsxt_virtual_services_count
# routed-сеть, которую разворачивает Edge (SingleNestedAttribute -> объект)
routed_net_configuration = {
ip_addr_pool = var.nsxt_ip_addr_pool
main_dns = var.nsxt_main_dns
second_dns = var.nsxt_second_dns
}
}
+583
View File
@@ -0,0 +1,583 @@
package templates
const Instance = `package resources_gen
import (
"context"
{{- if .NeedsStringsImport }}
"strings"
{{- end }}
{{- if .NeedsFmtImport }}
"fmt"
{{- end }}
"terraform-provider-nubes/internal/core"
"terraform-provider-nubes/internal/resources_core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
{{- if .NeedsInt64Default }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
{{- end }}
{{- if .NeedsStringDefault }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
{{- end }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
{{- if .NeedsBoolUseStateForUnknown }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
{{- end }}
{{- if .NeedsInt64UseStateForUnknown }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
{{- end }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// Code generated by TOOLS/resource-generator. DO NOT EDIT.
// Service: {{.Name}}
// Service ID: {{.ServiceID}}
var _ resource.Resource = &{{ToCamel .Name}}Resource{}
var _ resource.ResourceWithModifyPlan = &{{ToCamel .Name}}Resource{}
var _ resource.ResourceWithImportState = &{{ToCamel .Name}}Resource{}
type {{ToCamel .Name}}Resource struct {
client *core.UniversalClient
}
{{- range .SchemaParams }}
{{- if (IsNested .) }}
// {{NestedModelName $.Name .Code}} — вложенная модель для map-fixed параметра {{.Code}}.
type {{NestedModelName $.Name .Code}} struct {
{{- range .SubParams }}
{{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}" json:"{{.Code}}"` + "`" + `
{{- end }}
}
{{- end }}
{{- end }}
type {{ToCamel .Name}}Model struct {
ID types.String ` + "`" + `tfsdk:"id"` + "`" + `
ResourceName types.String ` + "`" + `tfsdk:"resource_name"` + "`" + `
OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + `
LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + `
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// git_revision: при изменении вызывает redeploy вместо modify.
// Опциональное поле — если не задано, modify работает как обычно.
GitRevision types.String ` + "`" + `tfsdk:"git_revision"` + "`" + `
{{- end }}
{{- range .SchemaParams }}
{{- if (IsNested .) }}
{{ToCamel .Code}} {{NestedTfType . $.Name}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
{{- else }}
{{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
{{- end }}
{{- end }}
{{- if .SupportsSuspendDestroy }}
SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + `
{{- end }}
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
{{- range .OutputParams }}
{{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
{{- end }}
}
func New{{ToCamel .Name}}Resource() resource.Resource {
return &{{ToCamel .Name}}Resource{}
}
func (r *{{ToCamel .Name}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_{{.Name}}"
}
func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
attrs := map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"resource_name": schema.StringAttribute{Required: true},
"operation_timeout": schema.StringAttribute{Optional: true},
"log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."},
{{- range .SchemaParams }}
{{- if (IsNested .) }}
"{{ToSnake .Code}}": {{NestedSchemaBlock .}}
{{- range .SubParams }}
"{{ToSnake .Code}}": schema.{{SubSchemaType .}}Attribute{
{{- if .Default }}Optional: true, Computed: true, Default: {{SubDefaultExpr .}},{{else if .Required}}Required: true,{{else}}Optional: true,{{end}}
{{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}}
},
{{- end }}
{{NestedSchemaEnd .}}
{{- else }}
"{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{
{{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}}
{{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (ShouldBeOptionalComputed .) }}Computed: true,{{- end }}
{{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}}
{{- if .Sensitive }}Sensitive: true,{{end}}
{{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{- else if ShouldUseStateForUnknown . }}PlanModifiers: []planmodifier.{{if eq (ParamType .) "types.Bool"}}Bool{boolplanmodifier.UseStateForUnknown()}{{else if eq (ParamType .) "types.Int64"}}Int64{int64planmodifier.UseStateForUnknown()}{{else}}String{stringplanmodifier.UseStateForUnknown()}{{end}},{{- end }}
},
{{- end }}
{{- end }}
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// При изменении git_revision вызывается redeploy вместо modify.
// Если не задано — modify работает без изменений.
"git_revision": schema.StringAttribute{Optional: true, MarkdownDescription: "Git revision (commit hash/tag). Changing this triggers redeploy instead of modify."},
{{- end }}
{{- if .SupportsSuspendDestroy }}
"suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})},
{{- end }}
"adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})},
{{- range .OutputParams }}
{{- if or (OutputIsMap .) (OutputIsList .) }}
"{{ToSnake .Code}}": schema.{{if OutputIsMap .}}Map{{else}}List{{end}}Attribute{Computed: true, ElementType: types.StringType{{if OutputSensitive .}}, Sensitive: true{{end}}},
{{- else }}
"{{ToSnake .Code}}": schema.StringAttribute{Computed: true{{if OutputSensitive .}}, Sensitive: true{{end}}},
{{- end }}
{{- end }}
}
resp.Schema = schema.Schema{Attributes: attrs}
}
func (r *{{ToCamel .Name}}Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
if r.client == nil {
return
}
var config *{{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
if config == nil {
return
}
var state *{{ToCamel .Name}}Model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// Destroy-план (plan == null): create-time проверку «уже существует / adopt»
// запускать нельзя — удаление не валидируется через существование инстанса.
if req.Plan.Raw.IsNull() {
return
}
if state != nil && !state.ID.IsNull() && !state.ID.IsUnknown() {
var plan {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
{{- if .HasRefSvcParams }}
// FIX(uuid-case): resolve ref_svc params НЕ делаем в ModifyPlan.
// Terraform правило: plan ОБЯЗАН равняться config для user-provided атрибутов.
// Resolve (displayName → UUID или uppercase → lowercase) нужен только для
// API-вызова в Create/Update. Делать его здесь = менять plan = ошибка
// "Provider produced invalid plan: planned value does not match config value".
{{- end }}
if !plan.ResourceName.IsNull() && !plan.ResourceName.IsUnknown() && !state.ResourceName.IsNull() && !state.ResourceName.IsUnknown() {
if plan.ResourceName.ValueString() != state.ResourceName.ValueString() {
resp.Diagnostics.AddError("Нельзя изменить resource_name", "Параметр resource_name задается при создании и не может быть изменен. Создайте новый ресурс с другим именем.")
return
}
}
{{- range .CreateOnlyParams }}
{{- if not (IsNested .) }}
if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() {
{{- if eq (ParamType .) "types.Bool" }}
if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() {
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
return
}
{{- else if eq (ParamType .) "types.Int64" }}
if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() {
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
return
}
{{- else }}
// FIX(uuid-case): сравниваем без учёта регистра — API возвращает UUID
// в lowercase, пользователь мог написать upper/mixed. Это одно и то же
// значение, менять его нельзя только если оно реально другое.
if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) {
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
return
}
{{- end }}
}
{{- end }}
{{- end }}
return
}
{{- range .SchemaParams }}
{{- if and .Required (eq (ParamDefaultExpr .) "") (not (IsNested .)) }}
if config.{{ToCamel .Code}}.IsNull() {
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
return
}
if config.{{ToCamel .Code}}.IsUnknown() {
return
}
{{- end }}
{{- end }}
// ⛔ Create-time проверка существования/усыновления здесь СОЗНАТЕЛЬНО НЕ вызывается.
//
// Причина: при tainted-ресурсе Terraform планирует ЗАМЕНУ (destroy+create), и
// create-узел замены приходит в ModifyPlan с prior state = null — ровно как у
// нового ресурса. Отличить «замену» от «создания» на этом уровне невозможно,
// поэтому проверка «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ» ложно срабатывала на
// ещё не удалённый инстанс и блокировала plan/destroy.
//
// Проверка осталась в Create (CreateExistingResourceDiagnosticsWithDomainAndServices):
// на apply она выполняется ПОСЛЕ удаления старого инстанса, поэтому конфликта уже нет.
}
func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
{{- range .SchemaParams }}
{{- if and .Required (eq (ParamDefaultExpr .) "") (not (IsNested .)) }}
if data.{{ToCamel .Code}}.IsNull() || data.{{ToCamel .Code}}.IsUnknown() {
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
return
}
{{- end }}
{{- end }}
{{- if .HasRefSvcParams }}
{{- range .CreateParams }}
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
// refSvc-поле резолвим только для API-запроса.
// config не перезаписываем: пользовательский display name или UUID должен
// пройти в state ровно в том виде, в котором его передал Terraform.
resolved{{ToCamel .Code}} := data.{{ToCamel .Code}}.ValueString()
if !data.{{ToCamel .Code}}.IsNull() && !data.{{ToCamel .Code}}.IsUnknown() {
var err error
resolved{{ToCamel .Code}}, err = r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, data.{{ToCamel .Code}}.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
}
{{- end }}
{{- end }}
{{- end }}
resourceName := data.ResourceName.ValueString()
desiredDomain := ""
{{- if .HasDomainParam }}
if !data.Domain.IsNull() && !data.Domain.IsUnknown() {
desiredDomain = data.Domain.ValueString()
}
{{- end }}
domainServiceIDs := []int{ {{- range .DomainServiceIDs }}{{.}}, {{- end }} }
resp.Diagnostics.Append(resources_core.CreateExistingResourceDiagnosticsWithDomainAndServices(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), desiredDomain, domainServiceIDs, {{.SupportsSuspendDestroy}})...)
// ⛔ Проверяем HasError ДО create — при hard-error (running без adopt, suspend без adopt,
// not created, конфликт) сайд-эффект create не должен выполняться.
if resp.Diagnostics.HasError() {
return
}
params := map[int]string{
{{- range .CreateParams }}
{{- if not (IsNested .) }}
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
{{.ID}}: resolved{{ToCamel .Code}},
{{- else }}
{{.ID}}: {{ParamFormat . (printf "data.%s" (ToCamel .Code))}},
{{- end }}
{{- end }}
{{- end }}
}
{{- range .CreateParams }}
{{- if (IsNested .) }}
if data.{{ToCamel .Code}} != nil {
params[{{.ID}}] = {{NestedJSONExpr . "data"}}
}
{{- end }}
{{- end }}
operationTimeout := ""
if !data.OperationTimeout.IsNull() && !data.OperationTimeout.IsUnknown() {
operationTimeout = data.OperationTimeout.ValueString()
}
if !data.LogLevel.IsNull() && !data.LogLevel.IsUnknown() {
ctx = core.CtxWithLogLevel(ctx, data.LogLevel.ValueString())
}
id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
data.ID = types.StringValue(id)
state, diags := resources_core.RefreshResourceState(ctx, r.client, id, {{.ServiceID}}, data, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *{{ToCamel .Name}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if state.ID.IsNull() || state.ID.IsUnknown() {
return
}
if r.client != nil {
remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.State.RemoveResource(ctx)
return
}
}
newState, diags := resources_core.RefreshResourceState(ctx, r.client, state.ID.ValueString(), {{.ServiceID}}, state, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}
func (r *{{ToCamel .Name}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan {{ToCamel .Name}}Model
var state {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
instanceID := state.ID
if instanceID.IsNull() || instanceID.IsUnknown() {
instanceID = plan.ID
}
if instanceID.IsNull() || instanceID.IsUnknown() {
resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра для modify")
return
}
hasServiceParamChanges := false
{{- range .ModifyParams }}
{{- if not (IsNested .) }}
if !hasServiceParamChanges {
if plan.{{ToCamel .Code}}.IsNull() != state.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() != state.{{ToCamel .Code}}.IsUnknown() {
hasServiceParamChanges = true
} else if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() {
{{- if eq (ParamType .) "types.Bool" }}
if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() {
hasServiceParamChanges = true
}
{{- else if eq (ParamType .) "types.Int64" }}
if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() {
hasServiceParamChanges = true
}
{{- else }}
if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) {
hasServiceParamChanges = true
}
{{- end }}
}
}
{{- end }}
{{- end }}
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// Проверяем изменился ли git_revision.
// Если да — вызываем redeploy вместо modify.
// git_revision = "" (не задано) → обычный modify.
redeployRequested := false
if !plan.GitRevision.IsNull() && !plan.GitRevision.IsUnknown() {
if state.GitRevision.IsNull() || state.GitRevision.IsUnknown() || plan.GitRevision.ValueString() != state.GitRevision.ValueString() {
redeployRequested = true
}
}
{{- end }}
if !hasServiceParamChanges{{if .HasRedeploy}} && !redeployRequested{{end}} {
// Сервисные параметры не изменились → modify НЕ вызываем.
//
// НО read-back поля (state_params*, state_out*, vault_*) обязаны быть
// перечитаны из инстанса. Раньше здесь слепо копировались значения из
// старого state, из-за чего tfstate хранил устаревшее значение:
// пример — state_params["needEnableAVI"]="false", когда на платформе уже
// true (модификатор включил ALB). Это давало ложный дрейф
// «Objects have changed outside of Terraform» на каждом plan.
plan.ID = instanceID
refreshed, refreshDiags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(refreshDiags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...)
return
}
operationTimeout := ""
if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() {
operationTimeout = plan.OperationTimeout.ValueString()
}
if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() {
ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString())
}
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// 1. Modify — только если изменились параметры сервиса
if hasServiceParamChanges {
{{- end }}
params := map[int]string{
{{- range .ModifyParams }}
{{- if not (IsNested .) }}
{{.ID}}: {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}},
{{- end }}
{{- end }}
}
{{- range .ModifyParams }}
{{- if (IsNested .) }}
if plan.{{ToCamel .Code}} != nil {
params[{{.ID}}] = {{NestedJSONExpr . "plan"}}
}
{{- end }}
{{- end }}
if err := resources_core.UpdateResourceWithTimeout(ctx, r.client, instanceID.ValueString(), params, operationTimeout); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
{{- if .HasRedeploy }}
}
// 2. Redeploy — только если изменился git_revision
if redeployRequested {
redeployParams := map[int]string{}
{{- range .RedeployParams }}
{{- if (IsNested .) }}
if plan.{{ToCamel .Code}} != nil {
redeployParams[{{.ID}}] = {{NestedJSONExpr . "plan"}}
}
{{- else }}
redeployParams[{{.ID}}] = {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}
{{- end }}
{{- end }}
if err := r.client.RunRedeployOperation(ctx, instanceID.ValueString(), operationTimeout, redeployParams); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
}
{{- end }}
plan.ID = instanceID
state, diags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if state.ID.IsNull() || state.ID.IsUnknown() {
return
}
{{- if .SupportsSuspendDestroy }}
deleteMode := "state_only"
if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() {
deleteMode = "suspend"
}
{{- else }}
deleteMode := "delete"
{{- end }}
operationTimeout := ""
if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() {
operationTimeout = state.OperationTimeout.ValueString()
}
if !state.LogLevel.IsNull() && !state.LogLevel.IsUnknown() {
ctx = core.CtxWithLogLevel(ctx, state.LogLevel.ValueString())
}
if err := resources_core.DeleteResourceWithTimeout(ctx, r.client, state.ID.ValueString(), deleteMode, operationTimeout); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
}
func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
func (r *{{ToCamel .Name}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Error", "Invalid client type")
return
}
r.client = client
}
`
+473
View File
@@ -0,0 +1,473 @@
// Package loader — загрузка YAML-спеков и построение GenResource/GenSubresource/GenAction.
//
// LoadSpecs — главная функция. Для каждого YAML:
// 1. Парсит операции (create/modify/delete/suspend/resume/...)
// 2. Классифицирует их на instance/subresource/action
// 3. Сливает параметры, вычисляет CreateOnly и ForceNew
// 4. Строит GenResource/GenSubresource/GenAction
// 5. Сортирует результат по имени сервиса
//
// ValidateSpec — fail-fast валидация (паника при неизвестном Kind).
package loader
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
"resource-generator/internal/params"
"resource-generator/internal/types"
"tf-tools/lib"
)
// LoadSpecs загружает все YAML-спеки из директории и строит модели всех ресурсов.
func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, []types.GenModifier, error) {
var services []types.GenResource
var subs []types.GenSubresource
var actions []types.GenAction
var modifiers []types.GenModifier
domainServiceIDsSet := map[int]struct{}{}
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
var spec types.ServiceSpec
if err := yaml.Unmarshal(b, &spec); err != nil {
return err
}
// P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях.
if err := ValidateSpec(path, &spec); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
createParams := []types.Param{}
modifyParams := []types.Param{}
supportsSuspendDestroy := false
for _, op := range spec.Operations {
if op.Kind == "modifier" {
name := strings.TrimSpace(op.Modifier)
if name == "" {
name = strings.TrimSpace(op.Action)
}
modifier := types.GenModifier{
ServiceName: spec.Name,
ServiceID: spec.ServiceID,
ModifierName: name,
OperationName: op.Action,
Params: ConvertParams(op.Params),
DeleteStrategy: normalizeDeleteStrategy(op.DeleteStrategy),
Idempotency: normalizeIdempotency(op.Idempotency),
}
modifier.DeleteParams = convertDeleteParams(op.DeleteParams)
modifier.SchemaParams = modifier.Params
for idx := range modifier.SchemaParams {
if modifier.SchemaParams[idx].HasSubParams {
modifier.SchemaParams[idx].IsJson = true
}
}
params.Analyze(&modifier.UsesBool, &modifier.UsesInt64, &modifier.UsesString, &modifier.HasDefaults, &modifier.NeedsBoolDefault, &modifier.NeedsInt64Default, &modifier.NeedsStringDefault, modifier.SchemaParams)
modifier.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(modifier.SchemaParams)
modifiers = append(modifiers, modifier)
continue
}
if op.Kind != "instance" {
continue
}
switch op.Action {
case "create":
createParams = ConvertParams(op.Params)
case "modify":
modifyParams = ConvertParams(op.Params)
case "suspend":
supportsSuspendDestroy = true
}
}
schemaParams := params.Merge(createParams, modifyParams)
createParams = params.AlignParamTypes(createParams, schemaParams)
modifyParams = params.AlignParamTypes(modifyParams, schemaParams)
createOnly := params.ComputeCreateOnly(createParams, modifyParams)
schemaParams = params.MarkCreateOnly(schemaParams, createOnly)
hasDomainParam := false
for _, param := range schemaParams {
if strings.EqualFold(strings.TrimSpace(param.Code), "domain") {
hasDomainParam = true
domainServiceIDsSet[spec.ServiceID] = struct{}{}
break
}
}
adoptExistingOnCreate := false
suspendOnDestroy := true
if spec.Lifecycle.SuspendOnDestroyDefault != nil {
suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault
}
gr := types.GenResource{
Name: spec.Name,
ServiceID: spec.ServiceID,
CreateParams: createParams,
ModifyParams: modifyParams,
SchemaParams: schemaParams,
CreateOnlyParams: params.FilterCreateOnly(schemaParams),
CreateOnlyRequiredParams: params.FilterCreateOnlyRequired(schemaParams),
OutputParams: NormalizeOutputParams(spec.Outputs.Params),
HasRefSvcParams: HasRefSvcParams(schemaParams),
SupportsSuspendDestroy: supportsSuspendDestroy,
SuspendOnDestroy: suspendOnDestroy,
AdoptExistingOnCreate: adoptExistingOnCreate,
HasDomainParam: hasDomainParam,
}
params.Analyze(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams)
gr.NeedsBoolUseStateForUnknown, gr.NeedsInt64UseStateForUnknown = analyzeUseStateForUnknown(gr.SchemaParams)
// Анализируем nested sub-params для default-импортов
if params.AnalyzeNestedDefaults(&gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) {
gr.NeedsFmtImport = true
}
gr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(gr.SchemaParams)
// Строковый import нужен только для обычных строковых сравнений и
// обработок в шаблоне. Старый restore-хук для refSvc больше не
// генерируется, поэтому отдельный флаг под него не нужен.
gr.NeedsStringsImport = params.AnalyzeNeedsStrings(gr.ModifyParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyRequiredParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyParams)
gr.NeedsFmtImport = params.HasNestedParams(gr.SchemaParams)
// --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) ---
for _, op := range spec.Operations {
if op.Kind != "action" {
continue
}
switch op.Action {
case "redeploy":
gr.HasRedeploy = true
gr.RedeployParams = ConvertParams(op.Params)
case "restart", "recovery", "reconcile":
// Исключены
default:
act := types.GenAction{
ServiceName: spec.Name,
ServiceID: spec.ServiceID,
ActionName: op.Action,
OperationName: op.Name,
Params: ConvertParams(op.Params),
}
act.SchemaParams = act.Params
params.Analyze(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams)
act.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(act.SchemaParams)
act.NeedsStringsImport = params.AnalyzeNeedsStrings(act.SchemaParams)
actions = append(actions, act)
}
}
services = append(services, gr)
srByName := map[string]*types.GenSubresource{}
for _, op := range spec.Operations {
if op.Kind != "subresource" {
continue
}
name := strings.TrimSpace(op.Subresource)
if name == "" {
continue
}
sr := srByName[name]
if sr == nil {
sr = &types.GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name}
srByName[name] = sr
}
switch op.Action {
case "create":
sr.CreateOpName = op.Name
sr.CreateParams = ConvertParams(op.Params)
case "modify":
sr.ModifyOpName = op.Name
sr.ModifyParams = ConvertParams(op.Params)
case "delete":
sr.DeleteOpName = op.Name
sr.DeleteParams = ConvertParams(op.Params)
}
}
for _, sr := range srByName {
sr.SchemaParams = params.Merge(sr.CreateParams, sr.ModifyParams, sr.DeleteParams)
sr.CreateParams = params.AlignParamTypes(sr.CreateParams, sr.SchemaParams)
sr.ModifyParams = params.AlignParamTypes(sr.ModifyParams, sr.SchemaParams)
sr.DeleteParams = params.AlignParamTypes(sr.DeleteParams, sr.SchemaParams)
createOnly := params.ComputeCreateOnly(sr.CreateParams, sr.ModifyParams)
sr.SchemaParams = params.MarkCreateOnly(sr.SchemaParams, createOnly)
forceNewCodes := params.BuildSubresourceForceNewCodes(sr, createOnly)
sr.SchemaParams = params.MarkForceNew(sr.SchemaParams, forceNewCodes)
sr.IdentityParams = params.FilterForceNew(sr.SchemaParams)
if len(sr.IdentityParams) == 0 {
sr.IdentityParams = sr.SchemaParams
}
sr.HasRefSvcParams = HasRefSvcParams(sr.SchemaParams)
params.Analyze(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams)
params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams)
sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams)
sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams)
subs = append(subs, *sr)
}
return nil
})
if walkErr != nil {
return nil, nil, nil, nil, walkErr
}
domainServiceIDs := make([]int, 0, len(domainServiceIDsSet))
for serviceID := range domainServiceIDsSet {
domainServiceIDs = append(domainServiceIDs, serviceID)
}
sort.Ints(domainServiceIDs)
for idx := range services {
if len(domainServiceIDs) == 0 {
services[idx].DomainServiceIDs = nil
continue
}
services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...)
}
sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
sort.Slice(subs, func(i, j int) bool {
if subs[i].ServiceName == subs[j].ServiceName {
return subs[i].SubName < subs[j].SubName
}
return subs[i].ServiceName < subs[j].ServiceName
})
sort.Slice(actions, func(i, j int) bool {
if actions[i].ServiceName == actions[j].ServiceName {
return actions[i].ActionName < actions[j].ActionName
}
return actions[i].ServiceName < actions[j].ServiceName
})
sort.Slice(modifiers, func(i, j int) bool {
if modifiers[i].ServiceName == modifiers[j].ServiceName {
return modifiers[i].ModifierName < modifiers[j].ModifierName
}
return modifiers[i].ServiceName < modifiers[j].ServiceName
})
return services, subs, actions, modifiers, nil
}
// ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов.
func ConvertParams(params []types.ParamSpec) []types.Param {
out := make([]types.Param, 0, len(params))
for _, p := range params {
typeName := NormalizeParamType(p.DataType)
defVal := NormalizeDefault(p.Default)
ref := 0
if p.RefSvcID != nil {
ref = *p.RefSvcID
}
isNested := len(p.SubParams) > 0
// Рекурсивно конвертируем SubParams для map-fixed/array-map-fixed.
subParams := make([]types.Param, 0, len(p.SubParams))
for _, sp := range p.SubParams {
sub := ConvertParams([]types.ParamSpec{sp})
if len(sub) > 0 {
subParams = append(subParams, sub[0])
}
}
out = append(out, types.Param{
ID: p.ID,
Code: p.Code,
Type: typeName,
Required: p.Required,
Default: defVal,
RefSvcId: ref,
Descr: strings.TrimSpace(p.Descr),
Man: strings.TrimSpace(p.Man),
Sensitive: p.IsSensitive,
IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"),
HasSubParams: isNested,
SubParams: subParams,
IsModifiable: p.IsModifiable,
})
}
return out
}
// NormalizeParamType нормализует строку data_type → bool | int64 | string | map-fixed | array-map-fixed.
func NormalizeParamType(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
if v == "map-fixed" || v == "array-map-fixed" {
return v
}
if strings.Contains(v, "bool") {
return "bool"
}
if strings.Contains(v, "int") || strings.Contains(v, "number") {
return "int64"
}
return "string"
}
// NormalizeDefault нормализует значение по умолчанию в строку.
func NormalizeDefault(value interface{}) string {
if value == nil {
return ""
}
switch t := value.(type) {
case string:
return strings.TrimSpace(t)
default:
return fmt.Sprintf("%v", t)
}
}
// NormalizeOutputParams нормализует выходные параметры.
func NormalizeOutputParams(params []types.OutputParam) []types.OutputParam {
if len(params) == 0 {
return []types.OutputParam{}
}
return params
}
// HasRefSvcParams проверяет наличие ref_svc-параметров.
func HasRefSvcParams(params []types.Param) bool {
for _, p := range params {
if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") {
return true
}
}
return false
}
// KnownKinds — допустимые значения Kind операций.
var KnownKinds = map[string]bool{
"instance": true,
"subresource": true,
"action": true,
"modifier": true,
}
// ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds.
// P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования.
func ValidateSpec(path string, spec *types.ServiceSpec) error {
if spec.Name == "" {
return fmt.Errorf("missing required field: name")
}
if spec.ServiceID <= 0 {
return fmt.Errorf("missing required field: service_id")
}
for i, op := range spec.Operations {
if op.Kind == "" {
return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name)
}
if !KnownKinds[op.Kind] {
return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action, modifier)", i, op.Name, op.Kind)
}
if op.Action == "" {
return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind)
}
if op.Kind == "subresource" && op.Subresource == "" {
return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name)
}
if op.Kind == "modifier" && strings.TrimSpace(op.Action) == "" {
return fmt.Errorf("operation[%d] %q (kind=modifier): missing required field: action", i, op.Name)
}
if op.Kind == "modifier" {
if err := validateModifierOperation(i, &op); err != nil {
return err
}
}
}
return nil
}
// normalizeDeleteStrategy возвращает каноническое значение delete_strategy.
// Пусто → noop_warn; неизвестное — как есть (валидация в ValidateSpec отклонит раньше).
func normalizeDeleteStrategy(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw))
if v == "" {
return "noop_warn"
}
return v
}
// normalizeIdempotency возвращает каноническое значение idempotency.
// Пусто → none.
func normalizeIdempotency(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw))
if v == "" {
return "none"
}
return v
}
// convertDeleteParams переносит lib.DeleteParam → types.DeleteParam (wire-строки).
func convertDeleteParams(raw []lib.DeleteParam) []types.DeleteParam {
if len(raw) == 0 {
return nil
}
out := make([]types.DeleteParam, 0, len(raw))
for _, p := range raw {
out = append(out, types.DeleteParam{Code: strings.TrimSpace(p.Code), Value: p.Value})
}
return out
}
// validateModifierOperation — fail-fast для полей modifier-операции.
func validateModifierOperation(i int, op *lib.OperationSpec) error {
ds := normalizeDeleteStrategy(op.DeleteStrategy)
switch ds {
case "noop_warn", "inverse", "error":
default:
return fmt.Errorf("operation[%d] %q (kind=modifier): unknown delete_strategy %q (valid: noop_warn, inverse, error)", i, op.Name, op.DeleteStrategy)
}
idem := normalizeIdempotency(op.Idempotency)
switch idem {
case "none", "check_before_run":
default:
return fmt.Errorf("operation[%d] %q (kind=modifier): unknown idempotency %q (valid: none, check_before_run)", i, op.Name, op.Idempotency)
}
if ds == "inverse" && len(op.DeleteParams) == 0 {
return fmt.Errorf("operation[%d] %q (kind=modifier): delete_strategy=inverse требует delete_params", i, op.Name)
}
// каждый delete_params.code обязан существовать среди params (по lower-code)
paramCodes := make(map[string]struct{}, len(op.Params))
for _, p := range op.Params {
paramCodes[strings.ToLower(strings.TrimSpace(p.Code))] = struct{}{}
}
for _, dp := range op.DeleteParams {
if _, ok := paramCodes[strings.ToLower(strings.TrimSpace(dp.Code))]; !ok {
return fmt.Errorf("operation[%d] %q (kind=modifier): delete_params.code %q отсутствует в params", i, op.Name, dp.Code)
}
}
return nil
}
// analyzeUseStateForUnknown определяет, нужны ли импорты boolplanmodifier/int64planmodifier:
// есть ли среди SchemaParams скалярный Optional+Computed (без Default) параметр
// соответствующего типа, для которого генерируется UseStateForUnknown().
func analyzeUseStateForUnknown(schemaParams []types.Param) (needsBool, needsInt64 bool) {
for _, p := range schemaParams {
if p.IsJson || p.Required || p.RefSvcId != 0 || p.Default != "" {
continue
}
switch strings.ToLower(p.Type) {
case "bool":
needsBool = true
case "int", "int64", "number":
needsInt64 = true
}
}
return needsBool, needsInt64
}
+57
View File
@@ -0,0 +1,57 @@
# =============================================================================
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
#
# Порядок строго такой:
# орга (создана вручную в ЛК)
# -> nubes_vc_vdc.vdc
# -> nubes_vc_nsxt.edge
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
#
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
# созданный vDC и Edge. Иначе modify на орге падает
# («Can't cast Complex Object Type Struct to String»).
# =============================================================================
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" {
organization = var.organization
vip_configure = jsonencode([
{
name = var.ip_space_name
count = var.ip_count
}
])
# false = при destroy отправить обратный modify с count=0 (квота обнулится)
keep_on_destroy = false
depends_on = [nubes_vc_nsxt.edge]
}
# 2. SNAT на эдже (modify: ipSpaceName)
resource "nubes_vc_nsxt_snat" "snat" {
nsxt_uid = nubes_vc_nsxt.edge.id
ip_space_name = var.ip_space_name
keep_on_destroy = false
# ipSpace должен быть уже выделен на организации
depends_on = [nubes_vc_org_ip_allocation.org_ip]
}
output "allocated_org_ip" {
description = "Выделено внешних IP на организации"
value = {
organization = var.organization
ip_space_name = var.ip_space_name
ip_count = var.ip_count
}
}
output "snat_ip_space" {
description = "ipSpace, включённый как SNAT на эдже"
value = nubes_vc_nsxt_snat.snat.ip_space_name
}
+157
View File
@@ -0,0 +1,157 @@
# =============================================================================
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
#
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
# или закомментировать ресурс.
#
# Порядок (чек-лист из инструкции на услугу в ЛК):
# 1) Организация в Cloud Director — создана вручную в ЛК
# 2) nubes_vc_vdc.vdc — есть
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
# 5) SNAT на Edge — nubes_vc_nsxt_snat
# 6) Kubernetes кластер Штурвал — этот ресурс
#
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
# =============================================================================
# --- Переменные Штурвала ---
variable "shturval_resource_name" {
type = string
default = "shturval-dev"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-00"
description = "Имя кластера внутри Штурвала"
}
variable "shturval_app_version" {
type = string
default = "2.14.0"
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
}
variable "shturval_cp_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
}
variable "shturval_cp_sizing_disk" {
type = number
default = 50
description = "Диск control plane, ГБ (минимум 50)"
}
variable "shturval_cp_count" {
type = number
default = 1
description = "Количество мастер-нод: 1, 3 или 5"
}
variable "shturval_worker_group_name" {
type = string
default = "workers-shturval-dev"
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
}
variable "shturval_worker_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
}
variable "shturval_worker_sizing_disk" {
type = number
default = 50
description = "Диск воркеров, ГБ (минимум 50)"
}
variable "shturval_worker_count" {
type = number
default = 1
description = "Количество воркер-нод (минимум 1)"
}
# --- Значения, которые собираются из переменных ---
locals {
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
# sizingDisk, count, autoscale, labelDeck.
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
# в snake_case — это ошибка генератора, платформа на них падает с
# «Cannot invoke method split() on null object» (не находит groupName → null).
shturval_worker_config = jsonencode([
{
groupName = var.shturval_worker_group_name
sizingPolicy = var.shturval_worker_sizing_policy
sizingDisk = var.shturval_worker_sizing_disk
count = var.shturval_worker_count
autoscale = false # автоскейл выключен
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
}
])
}
# --- Ресурс Штурвала ---
resource "nubes_k8s_sthutrval_cluster" "shturval" {
resource_name = var.shturval_resource_name
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
adopt_existing_on_create = true
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
# иначе провайдер сдаётся на дефолтных 600 с.
operation_timeout = "60m"
startup_configuration = {
# vDC и Edge из этого же конфига (обязательные поля)
vdc_uid = nubes_vc_vdc.vdc.id
nsxt_uid = nubes_vc_nsxt.edge.id
cluster_name = var.shturval_cluster_name
# Дополнительные возможности кластера (в ЛК — галочки при создании)
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
ex_local_csi = true
ex_vip = true
ex_update = true
ex_ingress = true
ex_named_csi = true
}
cluster_configuration = {
app_version = var.shturval_app_version
}
control_plane_configuration = {
sizing_policy = var.shturval_cp_sizing_policy
sizing_disk = var.shturval_cp_sizing_disk
count = var.shturval_cp_count
}
worker_configuration = local.shturval_worker_config
access_configuration = {
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
access_ip_list_api = jsonencode([]) # пусто = доступ всем
need_external_address_ingress = true # внешний адрес для Ingress
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
}
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
depends_on = [nubes_vc_nsxt_snat.snat]
}
+183
View File
@@ -0,0 +1,183 @@
// Package types — структуры данных для генератора Terraform-провайдера.
//
// Моделирует три вида ресурсов:
// - GenResource — основной CRUD инстанса (nubes_{service})
// - GenSubresource — подресурсы (nubes_{service}_{user}, ...)
// - GenAction — действия (почти не используется, redeploy встроен в GenResource.HasRedeploy)
//
// Правила обработки action'ов из ARCHITECTURE.md:
// - redeploy → HasRedeploy=true, поле git_revision в основном ресурсе
// - restart/recovery/reconcile → исключены (ручные, только UI)
// - всё остальное → отдельный GenAction
package types
import "tf-tools/lib"
// ─── Алиасы к lib (общий YAML-контракт) ─────────────────────────────────────
type OutputParam = lib.OutputParam
type OperationSpec = lib.OperationSpec
type ParamSpec = lib.ParamSpec
// ─── Входные структуры (из YAML) — локальное определение ────────────────────
// ServiceSpec — локальное определение (отличается вложенными типами от lib).
// Использует *bool для Lifecycle (совместимость с YAML-парсингом).
type ServiceSpec struct {
Name string `yaml:"name"` // snake_case имя (postgres, s3bucket, ...)
ServiceID int `yaml:"service_id"` // числовой ID сервиса в Nubes
Outputs struct {
Params []OutputParam `yaml:"params"`
} `yaml:"outputs"`
Lifecycle struct {
SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"`
AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"`
} `yaml:"lifecycle"`
Operations []OperationSpec `yaml:"operations"`
}
// Param — нормализованный параметр (после конвертации из ParamSpec).
type Param struct {
ID int
Code string
Type string
Required bool
Default string
RefSvcId int
Descr string
Man string
Sensitive bool
CreateOnly bool
ForceNew bool
// IsModifiable — флаг is_modifiable из API (можно менять в UI → можно менять в Terraform).
// nil означает «не указано» (как правило, изменяемо).
IsModifiable *bool
// IsJson — атрибут является JSON-строкой (data_type: json в YAML-спеке).
// При IsJson=true в схему добавляется JsonNormalize план-модификатор,
// чтобы план и API-ответ (компактный JSON) всегда совпадали.
IsJson bool
// HasSubParams — параметр имеет подполя (map-fixed или array-map-fixed).
HasSubParams bool
// SubParams — подполя для map-fixed/array-map-fixed параметров.
SubParams []Param
}
// GenResource — ресурс инстанса (nubes_{service}).
type GenResource struct {
Name string
ServiceID int
CreateParams []Param
ModifyParams []Param
SchemaParams []Param
CreateOnlyParams []Param
CreateOnlyRequiredParams []Param
OutputParams []OutputParam
HasRefSvcParams bool
SupportsSuspendDestroy bool
SuspendOnDestroy bool
AdoptExistingOnCreate bool
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsBoolUseStateForUnknown bool
NeedsInt64UseStateForUnknown bool
HasDomainParam bool
DomainServiceIDs []int
// HasRedeploy: сервис поддерживает redeploy (пересборка из git).
// Если true — в основной ресурс добавляется поле git_revision.
// При изменении git_revision вызывается redeploy вместо modify.
// Правило из ARCHITECTURE.md: только redeploy включается как inline action.
// restart, recovery, reconcile — исключены (ручные операции, только UI).
HasRedeploy bool
// RedeployParams — параметры redeploy-операции (если есть).
RedeployParams []Param
// NeedsJsonPlanMod: хотя бы один атрибут имеет data_type: json.
// Управляет добавлением импорта planmodifier в сгенерированный файл.
NeedsJsonPlanMod bool
// NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update).
NeedsStringsImport bool
// NeedsFmtImport: есть nested (map-fixed) параметры, нужен fmt.Sprintf.
NeedsFmtImport bool
}
// GenSubresource — подресурс (nubes_{service}_{sub}).
type GenSubresource struct {
ServiceName string
ServiceID int
SubName string
CreateOpName string
ModifyOpName string
DeleteOpName string
CreateParams []Param
ModifyParams []Param
DeleteParams []Param
SchemaParams []Param
IdentityParams []Param
HasRefSvcParams bool
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsBoolPlanMod bool
NeedsInt64PlanMod bool
NeedsStringPlanMod bool
NeedsJsonPlanMod bool
NeedsStringsImport bool
}
// GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource).
type GenAction struct {
ServiceName string
ServiceID int
ActionName string
OperationName string
Params []Param
SchemaParams []Param
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsJsonPlanMod bool
NeedsStringsImport bool
}
// GenModifier — отдельный ресурс для отложенной parent-level modify операции.
// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён.
type GenModifier struct {
ServiceName string
ServiceID int
ModifierName string
OperationName string
Params []Param
SchemaParams []Param
// DeleteStrategy — noop_warn | inverse | error (нормализовано из YAML, пусто → noop_warn).
DeleteStrategy string
// Idempotency — none | check_before_run (нормализовано из YAML, пусто → none).
Idempotency string
// DeleteParams — обратные значения (wire-строки), только при DeleteStrategy==inverse.
DeleteParams []DeleteParam
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsJsonPlanMod bool
}
// DeleteParam — обратное значение параметра inverse-Delete (code → wire-строка).
type DeleteParam struct {
Code string
Value string
}
@@ -0,0 +1,151 @@
# =============================================================================
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
#
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
# или закомментировать ресурс.
#
# Порядок (чек-лист из инструкции на услугу в ЛК):
# 1) Организация в Cloud Director — создана вручную в ЛК
# 2) nubes_vc_vdc.vdc — есть
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
# 5) SNAT на Edge — nubes_vc_nsxt_snat
# 6) Kubernetes кластер Штурвал — этот ресурс
#
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
# =============================================================================
# --- Переменные Штурвала ---
variable "shturval_resource_name" {
type = string
default = "shturval-dev"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-00"
description = "Имя кластера внутри Штурвала"
}
variable "shturval_app_version" {
type = string
default = "2.14.0"
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
}
variable "shturval_cp_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
}
variable "shturval_cp_sizing_disk" {
type = number
default = 50
description = "Диск control plane, ГБ (минимум 50)"
}
variable "shturval_cp_count" {
type = number
default = 1
description = "Количество мастер-нод: 1, 3 или 5"
}
variable "shturval_worker_group_name" {
type = string
default = "workers-shturval-dev"
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
}
variable "shturval_worker_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
}
variable "shturval_worker_sizing_disk" {
type = number
default = 50
description = "Диск воркеров, ГБ (минимум 50)"
}
variable "shturval_worker_count" {
type = number
default = 1
description = "Количество воркер-нод (минимум 1)"
}
# --- Значения, которые собираются из переменных ---
locals {
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
# sizingDisk, count, autoscale, labelDeck.
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
# в snake_case — это ошибка генератора, платформа на них падает с
# «Cannot invoke method split() on null object» (не находит groupName → null).
shturval_worker_config = jsonencode([
{
groupName = var.shturval_worker_group_name
sizingPolicy = var.shturval_worker_sizing_policy
sizingDisk = var.shturval_worker_sizing_disk
count = var.shturval_worker_count
autoscale = false # автоскейл выключен
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
}
])
}
# --- Ресурс Штурвала ---
resource "nubes_k8s_sthutrval_cluster" "shturval" {
resource_name = var.shturval_resource_name
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
# иначе провайдер сдаётся на дефолтных 600 с.
operation_timeout = "60m"
startup_configuration = {
# vDC и Edge из этого же конфига (обязательные поля)
vdc_uid = nubes_vc_vdc.vdc.id
nsxt_uid = nubes_vc_nsxt.edge.id
cluster_name = var.shturval_cluster_name
# Дополнительные возможности кластера (в ЛК — галочки при создании)
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
ex_local_csi = true
ex_vip = true
ex_update = true
ex_ingress = true
ex_named_csi = true
}
cluster_configuration = {
app_version = var.shturval_app_version
}
control_plane_configuration = {
sizing_policy = var.shturval_cp_sizing_policy
sizing_disk = var.shturval_cp_sizing_disk
count = var.shturval_cp_count
}
worker_configuration = local.shturval_worker_config
access_configuration = {
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
access_ip_list_api = jsonencode([]) # пусто = доступ всем
need_external_address_ingress = true # внешний адрес для Ingress
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
}
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
depends_on = [nubes_vc_nsxt_snat.snat]
}
+6
View File
@@ -0,0 +1,6 @@
provider_installation {
dev_overrides {
"tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes" = "/home/naeel/TF/tf_provider/TMP/devbin"
}
direct {}
}
@@ -116,6 +116,11 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault
} }
keepOnDestroy := false
if spec.Lifecycle.KeepOnDestroyDefault != nil {
keepOnDestroy = *spec.Lifecycle.KeepOnDestroyDefault
}
gr := types.GenResource{ gr := types.GenResource{
Name: spec.Name, Name: spec.Name,
ServiceID: spec.ServiceID, ServiceID: spec.ServiceID,
@@ -128,6 +133,7 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
HasRefSvcParams: HasRefSvcParams(schemaParams), HasRefSvcParams: HasRefSvcParams(schemaParams),
SupportsSuspendDestroy: supportsSuspendDestroy, SupportsSuspendDestroy: supportsSuspendDestroy,
SuspendOnDestroy: suspendOnDestroy, SuspendOnDestroy: suspendOnDestroy,
KeepOnDestroy: keepOnDestroy,
AdoptExistingOnCreate: adoptExistingOnCreate, AdoptExistingOnCreate: adoptExistingOnCreate,
HasDomainParam: hasDomainParam, HasDomainParam: hasDomainParam,
} }
@@ -79,6 +79,7 @@ type {{ToCamel .Name}}Model struct {
{{- if .SupportsSuspendDestroy }} {{- if .SupportsSuspendDestroy }}
SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + ` SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + `
{{- end }} {{- end }}
KeepOnDestroy types.Bool ` + "`" + `tfsdk:"keep_on_destroy"` + "`" + `
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
{{- range .OutputParams }} {{- range .OutputParams }}
{{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` {{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
@@ -128,6 +129,7 @@ func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.Sch
{{- if .SupportsSuspendDestroy }} {{- if .SupportsSuspendDestroy }}
"suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})}, "suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})},
{{- end }} {{- end }}
"keep_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.KeepOnDestroy}}), MarkdownDescription: "Режим state_only при destroy: ресурс не удаляется и не меняется в облаке, только убирается из состояния. Имеет приоритет над suspend_on_destroy. false = обычное удаление."},
"adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})}, "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})},
{{- range .OutputParams }} {{- range .OutputParams }}
{{- if or (OutputIsMap .) (OutputIsList .) }} {{- if or (OutputIsMap .) (OutputIsList .) }}
@@ -543,14 +545,15 @@ func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.Del
return return
} }
deleteMode := "delete"
{{- if .SupportsSuspendDestroy }} {{- if .SupportsSuspendDestroy }}
deleteMode := "state_only"
if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() { if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() {
deleteMode = "suspend" deleteMode = "suspend"
} }
{{- else }}
deleteMode := "delete"
{{- end }} {{- end }}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
deleteMode = "state_only"
}
operationTimeout := "" operationTimeout := ""
if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() { if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() {
@@ -563,6 +566,19 @@ func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.Del
resp.Diagnostics.AddError("Ошибка клиента", err.Error()) resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return return
} }
switch deleteMode {
case "suspend":
resp.Diagnostics.AddWarning(
"Ресурс заморожен, а не удалён",
"destroy: {{.Name}} (service_id={{.ServiceID}}) переведён в suspend, удаление не выполнялось. Для полного удаления выставьте suspend_on_destroy = false.",
)
case "state_only":
resp.Diagnostics.AddWarning(
"Ресурс оставлен как есть, а не удалён",
"destroy: {{.Name}} (service_id={{.ServiceID}}) не изменялся в облаке и только убран из состояния (keep_on_destroy = true). Для полного удаления выставьте keep_on_destroy = false.",
)
}
} }
func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
@@ -32,6 +32,9 @@ type ServiceSpec struct {
Lifecycle struct { Lifecycle struct {
SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"` SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"`
AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"` AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"`
// KeepOnDestroyDefault: дефолт ресурса для режима state_only (ничего не менять в облаке).
// Нужен там, где сервис не умеет suspend (например, Эдж) — иначе destroy удаляет объект.
KeepOnDestroyDefault *bool `yaml:"keep_on_destroy_default"`
} `yaml:"lifecycle"` } `yaml:"lifecycle"`
Operations []OperationSpec `yaml:"operations"` Operations []OperationSpec `yaml:"operations"`
} }
@@ -75,18 +78,21 @@ type GenResource struct {
HasRefSvcParams bool HasRefSvcParams bool
SupportsSuspendDestroy bool SupportsSuspendDestroy bool
SuspendOnDestroy bool SuspendOnDestroy bool
AdoptExistingOnCreate bool // KeepOnDestroy: destroy только убирает ресурс из состояния (state_only),
UsesBool bool // ничего не меняя в облаке. Приоритетнее suspend_on_destroy.
UsesInt64 bool KeepOnDestroy bool
UsesString bool AdoptExistingOnCreate bool
HasDefaults bool UsesBool bool
NeedsBoolDefault bool UsesInt64 bool
NeedsInt64Default bool UsesString bool
NeedsStringDefault bool HasDefaults bool
NeedsBoolUseStateForUnknown bool NeedsBoolDefault bool
NeedsInt64UseStateForUnknown bool NeedsInt64Default bool
HasDomainParam bool NeedsStringDefault bool
DomainServiceIDs []int NeedsBoolUseStateForUnknown bool
NeedsInt64UseStateForUnknown bool
HasDomainParam bool
DomainServiceIDs []int
// HasRedeploy: сервис поддерживает redeploy (пересборка из git). // HasRedeploy: сервис поддерживает redeploy (пересборка из git).
// Если true — в основной ресурс добавляется поле git_revision. // Если true — в основной ресурс добавляется поле git_revision.
// При изменении git_revision вызывается redeploy вместо modify. // При изменении git_revision вызывается redeploy вместо modify.
@@ -154,12 +160,12 @@ type GenAction struct {
// GenModifier — отдельный ресурс для отложенной parent-level modify операции. // GenModifier — отдельный ресурс для отложенной parent-level modify операции.
// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён. // Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён.
type GenModifier struct { type GenModifier struct {
ServiceName string ServiceName string
ServiceID int ServiceID int
ModifierName string ModifierName string
OperationName string OperationName string
Params []Param Params []Param
SchemaParams []Param SchemaParams []Param
// DeleteStrategy — noop_warn | inverse | error (нормализовано из YAML, пусто → noop_warn). // DeleteStrategy — noop_warn | inverse | error (нормализовано из YAML, пусто → noop_warn).
DeleteStrategy string DeleteStrategy string
// Idempotency — none | check_before_run (нормализовано из YAML, пусто → none). // Idempotency — none | check_before_run (нормализовано из YAML, пусто → none).
+1 -1
View File
@@ -5,7 +5,7 @@
| Стенд | Namespace | Версия | Дата заливки | | Стенд | Namespace | Версия | Дата заливки |
|---|---|---|---| |---|---|---|---|
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) | | PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
| DEV | `nubes-dev` | `2.0.13` | 2026-09-22 | (fix: ShouldRemoveFromState читает deleted/404 через GetInstanceStateRaw — тихое удаление из state без падения на дрейфе) | | DEV | `nubes-dev` | `2.0.23` | 2026-09-24 | (fix: UUID внутри JSON нормализуется к lowercase при сравнении (adopt suspended-инстанса падал на регистре `nsxtUid`) + `JsonNormalize` приводит UUID-подстроки; тесты) |
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) | | TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
## Как проверить ## Как проверить
@@ -0,0 +1,206 @@
# Как работает провайдер Nubes: поведение и отличия от канонического Terraform
> Страница для тех, кто уже работал с Terraform и хочет понять, чего ожидать от провайдера Nubes,
> и для DevOps, которым важно знать, что реально произойдёт в облаке при `plan`, `apply` и `destroy`.
> Здесь описан наблюдаемый контракт (что уже проверено на живых стендах) и ограничения платформы.
## 1. Суть в одном абзаце
Провайдер Nubes — это не плагин к гипервизору, а **обёртка над API личного кабинета**: каждый Terraform-ресурс
соответствует *инстансу услуги* в облаке, а `create` / `update` / `delete` транслируются в **асинхронные операции**
платформы (`create`, `modify`, `suspend`, `resume`, `delete`). Отсюда и все отличия от привычного Terraform:
- операции **долгие** — провайдер ждёт их завершения (поллинг);
- меняется **уже созданный объект**, а не создаётся заново;
- ошибки приходят **внутри тела ответа**, а не HTTP-кодом;
- ресурс ищется **по имени**, а не только по `id`;
- удаление может быть **«мягким»** — объект остаётся в облаке.
## 2. Какие ресурсы за что отвечают
| Услуга облака | Ресурс Terraform | Что делает |
|---|---|---|
| Организация в Cloud Director (19) | `nubes_vc_org_ip_allocation` | **модификатор**: меняет квоту внешних IP в организации (саму организацию создают в ЛК, Terraform её не трогает) |
| Виртуальный датацентр (21) | `nubes_vc_vdc` | создаёт vDC с ресурсами (CPU/RAM/Storage) |
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt` | создаёт Edge (routed-сеть, ALB/AVI) |
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt_snat` | **модификатор**: включает/переключает SNAT (`ipSpaceName`) на существующем Edge |
| Kubernetes кластер Штурвал (150) | `nubes_k8s_sthutrval_cluster` | создаёт кластер (control plane + группы воркеров) |
Общее правило. Ресурс, у которого есть свой «объект в облаке», **создаёт** этот объект. А то, что платформа
меняет операцией `modify` (квота IP, включение SNAT, настройки шлюза), собрано в отдельные ресурсы-
**модификаторы**: они не создают ничего нового, а правят уже существующий объект.
## 3. Отличия от «учебного» Terraform
| Ожидание по канону | Как в Nubes | Причина |
|---|---|---|
| `create` = один вызов API | Создание инстанса — **последовательность**: `POST /instances` → `POST /instanceOperations` → параметры (`instanceOperationCfsParams`) → `run` → поллинг до `dtFinish` | Так устроен API платформы |
| `id` — произвольная строка | `id` = UUID инстанса в облаке; провайдер читает его из ответа платформы | Состояние живёт в облаке, не в Terraform |
| `update` = замена при несовместимых параметрах | `update` = операция **`modify`** над тем же инстансом; часть параметров — **create-only** (менять нельзя → ошибка на этапе plan) | Платформа не пересоздаёт объекты «из коробки» |
| есть `data sources` для поиска существующего | Поиск/ссылки — через **ref-параметры**: можно указать UUID **или имя** инстанса | Экономит data sources, но требует дисциплины в значениях |
| `import` — явная команда | Плюс к `import` есть **авто-усыновление** `adopt_existing_on_create = true`: ресурс сам находит инстанс **по имени** | В проде объекты живут неделями, и пересоздавать их нельзя |
| `plan` показывает, что ресурс уже существует | **Нет**: проверка «инстанс с таким именем уже есть / adopt» выполняется в `Create`, то есть на `apply` | Иначе ломается `destroy` и работа с tainted-ресурсами |
| `delete` удаляет | `delete` может быть `delete` / `suspend` / `state_only` — см. §5 | У платформы не всё удаляется, а часть объектов удалять нельзя, пока жив потребитель |
| ошибка приходит HTTP-кодом | Ошибка операции — **в теле**: `isSuccessful=false` + `errorLog`, при успешном HTTP-коде | API платформы отвечает 200/201 почти всегда |
| операции быстрые | Операции асинхронные и долгие (Штурвал — десятки минут) → параметр `operation_timeout`; одновременные операции на одном инстансе не поддерживаются | Наследство платформы (внутри — CFS-оркестратор) |
| план обязан совпадать с config | Тоже, но: **ref-параметры в плане остаются как написаны** (`name` не подменяется на `uuid`), а уже перед вызовом API провайдер сам разберётся, имя это или UUID | Иначе Terraform упадёт с «Provider produced invalid plan» |
| параметры сравниваются как строки | JSON-параметры сравниваются **канонично**: порядок ключей и пробелы не важны, скаляры приводятся к строке, UUID — без учёта регистра | API возвращает JSON в своём виде, иначе будет «вечный diff» |
| идентичность — по `id` | Дополнительно: инстанс ищется по `resource_name` (displayName) → **переименование = новый ресурс** | Имя задаётся при создании и не меняется |
### Что из этого следует на практике
- `terraform plan` **не может** проверить, что «такой объект уже есть»: он это покажет как `will be created`,
а разбираться будет `apply`.
- `terraform apply` на существующей инфраструктуре — это нормальный сценарий (adopt), если включён
`adopt_existing_on_create`; без флага вы получите явную ошибку-конфликт, а не дубль ресурса.
- Долгие операции требуют терпения: смотрите `operation_timeout`, не запускайте вторую операцию по тому же объекту.
## 4. Создание, чтение, изменение, удаление
**Создание.** Провайдер формирует параметры операции, отправляет её и ждёт завершения. Параметры, зависящие
от других ресурсов (например `vdc_uid`, `nsxt_uid`), передаются как ref-значения: UUID или имя.
**Чтение (refresh).** Значения `state_params` / `state_out` приходят из облака: так в state попадают
адреса (`kubernetesApiAddress`, `ingressAddress`), имена, текущие параметры.
**Изменение.** Если параметры поменялись, вызывается `modify` (для модификаторов — обратный `modify` при удалении).
Параметры, помеченные как create-only, изменить нельзя — провайдер скажет об этом до обращения к API.
**Удаление.** См. следующий раздел — это самое неочевидное место.
## 5. Удаление: `delete`, `suspend` и «оставить как есть»
У платформы три разных исхода, и провайдер умеет все три. Управляется флагами:
| Флаг | Где применим | Поведение при `destroy` | Предупреждение в выводе |
|---|---|---|---|
| `suspend_on_destroy = true` | кластер Штурвала, vDC (по умолчанию `true`) | объект **приостанавливается**, не удаляется | «Ресурс заморожен, а не удалён» |
| `keep_on_destroy = true` | Edge, SNAT, квота IP (по умолчанию `false`) | объект **не трогается в облаке**, только убирается из state | «Оставлен как есть» (для SNAT — «SNAT не выключался», для квоты IP — «Аллокация IP не снималась») |
| оба `false` | любой | обычное удаление | — |
Если выставлены оба флага, победит `keep_on_destroy` (объект просто не тронут). По умолчанию провайдер
удаляет по-настоящему (`keep_on_destroy = false`), а «заморозку» или «оставить как есть» включают явно
в конфигурации стенда.
### Почему Edge остаётся `running`
Edge **физически не умеет `suspend`**: в списке доступных операций шлюза есть только `delete`, `modify`,
`reconcile`. Усыпить его платформа не даёт, поэтому единственный корректный способ «не удалять шлюз» —
не трогать его: `keep_on_destroy = true`. Побочный эффект — шлюз продолжает работать и тарифицироваться,
и через него продолжают публиковаться внешние адреса (API кластера и ingress). Именно поэтому удаление Edge
при живом кластере Штурвала (или других зависимых объектах: vApp, VM) считается недопустимым.
### Почему SNAT остаётся включённым, а квота IP не обнуляется
- SNAT-модификатор с `keep_on_destroy = true` **не отправляет** обратный `modify` с `ipSpaceName = "no-needed"`,
то есть SNAT для виртуальных машин остаётся в прежнем состоянии.
- Квота внешних IP: уменьшать `count` **ниже фактически занятых адресов платформа не разрешает**
(ошибка вида «Кол-во занятых Ip … Невозможно выставить параметр count ниже этого параметра»).
Адреса держит кластер Штурвала (API + ingress), и `suspend` кластера их **не освобождает**.
Поэтому при «заморозке» квота не изменяется вовсе, а реальное освобождение адресов возможно только после
удаления кластера — отдельным шагом.
### Почему кластер и vDC уходят в `suspend`
Для них `suspend` поддерживается платформой, и это самый близкий к «выключить» вариант: ВМ останавливаются,
объекты не удаляются, адреса и конфигурация сохраняются. Обратный ход делает `apply`:
| Что | При `destroy` (заморозка) | При следующем `apply` |
|---|---|---|
| Кластер Штурвала | `suspend` | adopt по имени + `resume` |
| vDC | `suspend` | adopt по имени + `resume` |
| Edge | не трогается (`running`) | adopt (инстанс уже работает) |
| SNAT | не трогается (включён) | повторный `modify` теми же значениями (фактически no-op) |
| Квота IP | не трогается | `modify` с тем же `count` (no-op) |
Итого цикл «`destroy` → `apply`» на стенде выглядит как «усыпить → разбудить», а не «снести → поднять заново».
Полностью удалить такой стенд можно только явным отказом от заморозки (`keep_on_destroy = false` /
`suspend_on_destroy = false`) и в правильном порядке (см. §6).
## 6. Конкретный пайплайн стенда: vDC → Edge → внешние IP → SNAT → Штурвал
Порядок создания и зависимости:
1. Организация — **вручную в ЛК** (Terraform её не создаёт).
2. `nubes_vc_vdc` — виртуальный датацентр.
3. `nubes_vc_nsxt` — Edge. Обязательно включать балансировщик (параметр `need_enable_avi = true`)
и задать не меньше 3 виртуальных сервисов (`virtual_services_count >= 3`) — это нужно кластеру Штурвала.
4. `nubes_vc_org_ip_allocation` — внешние адреса в организации (минимум 3 по инструкции услуги Штурвала).
5. `nubes_vc_nsxt_snat` — SNAT на Edge (по `depends_on` после аллокации адресов).
6. `nubes_k8s_sthutrval_cluster` — кластер Штурвала (по `depends_on` после SNAT: нодам нужен выход в интернет).
При удалении Terraform идёт в обратном порядке. Ограничения платформы, которые встречаются на этом пути:
- **1 кластер Штурвала = 1 vDC** (действующее ограничение услуги).
- vDC удаляется только **через 14 дней после `suspend`**; при живых Edge/vApp/VM/кластере — через поддержку.
- Edge не удаляется при живых зависимых объектах (по инструкции услуги).
- Квота IP не опускается ниже занятых адресов (см. §5).
Как проверить, что конфигурация корректна: `terraform plan` после `apply` должен говорить
`No changes`. Если появляется стабильный diff — сверяйте его с `terraform state show` и с тем,
что реально показывается в личном кабинете.
## 7. Регистр UUID и канонизация значений
UUID в облаке не имеет «правильного» регистра: один и тот же идентификатор может прийти как `2c37fed1-…`
и как `2C37FED1-…`. Поэтому провайдер сравнивает UUID **без учёта регистра** — в том числе внутри JSON-параметров
(например, `startupConfiguration` кластера Штурвала). Подробный разбор проблемы и всех мест, где она может
проявиться, — в `60_strategy/terraform_case_sensitivity_fix.md` (внутренний документ).
Для JSON-параметров сравнение также игнорирует порядок ключей и пробелы, а скаляры приводит к строковому виду —
это нужно, чтобы `plan` не показывал «изменение» там, где API вернул то же значение в другом формате.
## 8. Чек-лист DevOps
- Пин версии провайдера привязан к стенду: **prod `1.*`, dev `2.*`, test `3.*`**; после смены версии —
`terraform init -upgrade`.
- Перед `apply` и после — смотрите `plan`; ожидаемый финальный результат — `No changes`.
- **Читайте предупреждения `destroy`**: «заморожен», «оставлен как есть», «SNAT не выключался» — это не косметика,
а отчёт о том, что объекты продолжают жить и тарифицироваться.
- Не удаляйте объекты стенда руками в ЛК между `destroy` и `apply`: авто-усыновление ищет их по имени, и на удалённом
объекте упрётся в состояние `not created`.
- Долгие операции: задавайте `operation_timeout` (для Штурвала — `60m`), не запускайте параллельные операции
по одному объекту.
- Проверяя состояние через API ЛК, помните про обязательные заголовки (браузерный `User-Agent` и `Referer`) —
иначе отдаёт `403`.
## 9. FAQ
**Почему `plan` пишет `will be created`, если объект уже есть?**
Потому что проверка существования и усыновление выполняются на `apply` (в `Create`). В state ресурса нет → план
честно планирует создание. Решение — `adopt_existing_on_create = true`.
**Почему `destroy` сказал `5 destroyed`, а в облаке всё живо?**
Сработала «заморозка»: кластер и vDC приостановлены, Edge, SNAT и квота IP не изменялись. Terraform «удалил»
ресурсы только из своего состояния. Смотрите предупреждения в выводе.
**Почему IP не освободились?**
Квота не может стать меньше числа занятых адресов, а их держит кластер. Пока кластер существует (даже в `suspend`),
платформа не даст уменьшить `count`.
**Почему Edge нельзя приостановить?**
У платформы для шлюза нет операции `suspend` — только `delete`, `modify`, `reconcile`.
**Почему после `apply` кластер ожил сам?**
Сработало усыновление: ресурс нашёл инстанс по имени (`adopt_existing_on_create`), увидел статус `suspended`
и выполнил `resume`.
**State пуст, а объекты в облаке есть. Что делать?**
Это ожидаемый результат «заморозки». `terraform apply` в том же каталоге усыновит объекты обратно.
**Кто-то удалил объект в ЛК. Что будет?**
Усыновление не найдёт его и провайдер завершится ошибкой с указанием статуса (`not created`) — нужно либо создать
объект заново, либо разбираться с конфигурацией.
**А можно всё-таки удалить стенд полностью?**
Да, но осознанно: выставить `keep_on_destroy = false` и `suspend_on_destroy = false`, затем удалять в порядке
кластер → квота IP → SNAT → Edge → vDC, при необходимости — через поддержку (для vDC действует правило 14 дней).
## 10. Куда смотреть дальше
- `curated/pipeline/vdc_edge_ip_snat.md` — пошаговый разбор цепочки vDC → Edge → IP → SNAT.
- `curated/modifiers/org_ip_and_snat.md` — ресурсы-модификаторы.
- `30_registry/guides/terraform-structure.md` — структура манифестов.
- Внутренние документы (не публикуются): `60_strategy/provider_philosophy.md`,
`60_strategy/modifier_resources_ideology_and_specification.md`,
`60_strategy/adopt_ref_validation.md`, `60_strategy/terraform_case_sensitivity_fix.md`.
@@ -239,5 +239,59 @@ gr.NeedsStringsImport = hasRestoreCasingParams(gr.SchemaParams) || analyzeNeedsS
## 9. Версия ## 9. Версия
Фикс введён в версии провайдера **5.0.46**. Фикс введён в версии провайдера **5.0.46** (старая линия; актуальные линии — prod `1.*`, dev `2.*`, test `3.*`, см. §10).
Сгенерированные ресурсы пересозданы после изменения генератора. Сгенерированные ресурсы пересозданы после изменения генератора.
---
## 10. Обновление 2026-09-24: UUID внутри JSON (важно)
**Что уточнилось.** Посылка «API всегда возвращает lowercase» **неверна**. На живом стенде (Shturval dev-00)
один и тот же UUID приходил в разных регистрах: `vdcUid` — `d0937335-…` (lowercase), а `nsxtUid` кластера —
`2C37FED1-…` (UPPERCASE). Значит ориентироваться на «API нормализует» нельзя: сравнивать нужно всегда
без учёта регистра.
**Где вылезло.** Первый `apply` после «заморозки» стенда упал на усыновлении приостановленного кластера:
```
Error: required params mismatch for resource_name shturval-dev: startupConfiguration
(plan={… "nsxtUid":"2c37fed1-…" }, actual={… "nsxtUid":"2C37FED1-…" }).
```
**Почему предыдущие пять фиксов не помогли.** Они закрывали: отправку в API (`core/refsvc.go`,
`core/refsvc_resolve.go`), сравнение **одиночных** значений (`resources_core/params_compare.go`,
`normalizeCompareValue`), сравнение create-only атрибутов (`strings.EqualFold` в шаблоне генератора)
и восстановление регистра в state. Ни один из них не смотрит **внутрь JSON**, а adopt приостановленного
инстанса сравнивает параметр целиком как JSON:
`RequiredParamsMismatch` → `paramsEquivalent` → `JSONStringsEquivalent` → `jsonutil.normalizeJSONScalarsToStrings`,
где строки возвращались как есть (`case string: return val`). У Штурвала ref-параметры упакованы в JSON
(`startupConfiguration`), а путь adopt-suspended задействован впервые.
**Аудит: где регистр UUID может вылезти (проверено 24.09).**
| # | Место | Что ломает |
|---|---|---|
| 1 | `resources_core/required_params_compare.go` (`paramsEquivalent` → `JSONStringsEquivalent`) | adopt приостановленного инстанса — hard error (этот кейс) |
| 2 | `core/modifier_compare.go` | ложное «параметр изменился» → лишний `modify` на каждом apply |
| 3 | `resources_core/state_refresh.go` (сохранение планового JSON) | в state уедет регистр API вместо значения из config |
| 4 | `resources_core/resource_diagnostics_required.go` (create-диагностика) | та же `RequiredParamsMismatch` |
| 5 | `resources_core/params_compare.go` (`ParamsMatchForResume`) | одиночный UUID ок, JSON — та же дыра |
| 6 | `resources_core/json_planmodifier.go` (`JsonNormalize()`) | для JSON-атрибутов с UUID внутри — риск вечного diff |
| 7 | `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`) | ref-параметр, зашитый внутрь JSON, **не проверяется вообще** (открыто) |
| 8 | `core/operation_run*.go` (`lookupLiveParam`) | подстановка live-значений по ключам: при другом регистре ключа может молча не сработать (открыто, требует живой проверки) |
**Фикс (провайдер `2.0.23`, dev).**
- `internal/core/jsonutil/jsonutil.go`: добавлен `LowercaseUUIDsInText` (UUID-подстрока → lowercase), и строковые
значения **внутри JSON** теперь нормализуются в `normalizeJSONScalarsToStrings` — закрывает пункты 1–5.
- `internal/resources_core/json_planmodifier.go`: `JsonNormalize()` после `json.Compact` приводит UUID-подстроки
к lowercase (типы и порядок ключей **не** меняются — план обязан совпадать с config) — закрывает пункт 6.
- Тесты: `internal/core/jsonutil/jsonutil_test.go`, `internal/resources_core/params_compare_test.go`
(в т.ч. на реальном `startupConfiguration` кластера).
**Дополнение к алгоритму диагностики (§8):**
7. Если расхождение — внутри JSON-параметра, проверьте именно UUID-подстроки и их регистр (не только одиночные
значения); смотрите `jsonutil.LowercaseUUIDsInText`.
8. Не «лечите» это нормализацией плана целиком (скаляры → строки, сортировка ключей): для атрибутов из config
допустимо менять только регистр UUID-подстрок, иначе Terraform ругнётся на несоответствие плана конфигу.
-46
View File
@@ -1,46 +0,0 @@
# Что НЕ надо класть в Terraform и почему
## Не управляется Terraform — делается в Личном кабинете (один раз)
### vcOrg / vcVdc / vcNsxt — облачный фундамент
| Сервис | Почему не Terraform |
|--------|---------------------|
| **Организация в Cloud Director** (vcOrg) | Одна на resources realm. Нельзя создать вторую. При destroy — карантин на 14 дней, блокирует всё. |
| **Виртуальный датацентр vDC** (vcVdc) | Квота: 1 на организацию. Создание через Terraform ломается на API (500 на `GET cfsParams`). |
| **Сетевой шлюз Edge** (vcNsxt) | Квота: 1 на vDC. Требует AVI, SNAT, ipSpace — настройка в 3 шага (create → modify org → modify edge). Terraform не умеет create+modify в одном apply. |
**Вывод**: фундамент всегда уже есть у любого клиента. Создаётся один раз через ЛК.
### Штурвал (Kubernetes-кластер)
| Почему не Terraform |
|---------------------|
| Одноразовая операция. Модификаций почти нет — workers, версия, диски, и то редко. |
| Destroy = потеря кластера со всеми подами и данными. `suspend_on_destroy` спасает, но риск велик. |
| Процесс создания длительный, чувствителен к таймаутам и сбоям — в ЛК виднее что происходит. |
## Что Управляется Terraform — PaaS/SaaS слой
```
PostgreSQL Redis Kafka ClickHouse RabbitMQ MariaDB MongoDB
S3 Buckets Node.js Lucee Gitea Nextcloud Harbor ...
```
Эти сервисы создаются/меняются/удаляются многократно, имеют повторяемые конфигурации и связаны зависимостями (app → db, app → s3).
## Схема
```
┌─ Личный кабинет (один раз) ──────────┐
│ vcOrg → vcVdc → vcNsxt (Edge+AVI) │
│ Штурвал (Kubernetes-кластер) │
└──────────────────────────────────────┘
│ edge_uid
▼
┌─ Terraform (IaC, повторяемо) ────────┐
│ Postgres, S3, Redis, Kafka, │
│ RabbitMQ, ClickHouse, MariaDB, │
│ MongoDB, Node.js, Lucee, Gitea ... │
└──────────────────────────────────────┘
```
+74
View File
@@ -0,0 +1,74 @@
# Ресурсы-модификаторы: внешние IP организации и SNAT на шлюзе
Два ресурса работают с **уже существующими** услугами:
- `nubes_vc_org_ip_allocation` — выделяет внешние IP на организации;
- `nubes_vc_nsxt_snat` — включает SNAT на сетевом шлюзе периметра.
Эти параметры нельзя задать при создании услуги обычным ресурсом, поэтому они вынесены в отдельные
ресурсы: так вся цепочка поднимается одним `apply` и в правильном порядке.
Оба ресурса проверены живым прогоном: создание, проверка состояния в ЛК и удаление.
Полный рабочий пример (vDC + Edge + IP + SNAT) — на странице
[«Как развернуть vDC, Edge, внешние IP и SNAT»](../pipeline/vdc_edge_ip_snat.md),
файлы — в репозитории `tf_examples`, папка `fullpipe_chain`.
## `nubes_vc_org_ip_allocation` — внешние IP на организации
| Атрибут | Тип | Описание |
|---|---|---|
| `organization` | string, обязательный | Организация: название услуги из ЛК или её UUID |
| `vip_configure` | string (JSON), обязательный | Сколько IP выделить: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строкой |
| `keep_on_destroy` | bool, по умолчанию `false` | `false` — при удалении квота обнуляется; `true` — остаётся как есть |
```hcl
resource "nubes_vc_org_ip_allocation" "this" {
organization = "organ" # название услуги из ЛК
vip_configure = jsonencode([
{ name = "internet-ipv4-v1", count = "3" }
])
keep_on_destroy = false
}
```
Как это работает:
- массив `vip_configure` записывается **целиком**, поэтому ресурс владеет всей аллокацией организации:
если нужно поменять количество — меняйте значение и делайте `apply`;
- снять аллокацию через пустой массив нельзя — для этого удалите ресурс
(`terraform destroy -target=nubes_vc_org_ip_allocation.this`), тогда отправится запрос с `count = "0"`.
## `nubes_vc_nsxt_snat` — SNAT на шлюзе
| Атрибут | Тип | Описание |
|---|---|---|
| `nsxt_uid` | string, обязательный | UUID услуги «Сетевой шлюз периметра (Edge)» |
| `ip_space_name` | string, обязательный | Имя ipSpace для внешнего IP; значение `no-needed` выключает SNAT |
| `keep_on_destroy` | bool, по умолчанию `false` | `false` — при удалении SNAT выключается; `true` — остаётся как есть |
```hcl
resource "nubes_vc_nsxt_snat" "this" {
nsxt_uid = nubes_vc_nsxt.edge.id # UUID шлюза из этого же конфига
ip_space_name = "internet-ipv4-v1"
depends_on = [nubes_vc_org_ip_allocation.this]
}
```
Если шлюз создаётся в этом же конфиге, UUID искать вручную не нужно — используйте
`nubes_vc_nsxt.edge.id`. Если шлюз уже существует, UUID виден в карточке услуги в ЛК.
Имя ipSpace должно быть сначала выделено на организации, поэтому у ресурса есть `depends_on`
на аллокацию IP.
## Импорт
Если услуга уже настроена (например, в ЛК вручную), ресурс можно подключить к Terraform:
```bash
terraform import nubes_vc_org_ip_allocation.this organ # организация: имя или UUID
terraform import nubes_vc_nsxt_snat.this <uuid услуги шлюза> # UUID из карточки услуги в ЛК
```
+118
View File
@@ -0,0 +1,118 @@
# Как развернуть vDC, Edge, внешние IP и SNAT
Пошаговая инструкция. Готовые файлы примера — в репозитории `tf_examples`, папка `fullpipe_chain`.
Что получится в итоге:
- виртуальный датацентр (vDC);
- сетевой шлюз периметра (Edge);
- внешние IP на организации;
- SNAT на шлюзе.
Кластер Штурвал в эту инструкцию не входит — он разворачивается долго, отдельным шагом.
**Организацию создайте заранее в ЛК** — Terraform её не создаёт и не удаляет.
## 1. Скачайте пример
```bash
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
cd tf_examples/fullpipe_chain
```
Понадобится Terraform 1.5 или новее. Провайдер скачается сам при `terraform init`.
## 2. Возьмите значения в ЛК
| Значение | Где взять | Пример |
|---|---|---|
| `api_token` | ЛК → Профиль → Токены → «Технический» | `eyJhbGciOi...` |
| `organization` | ЛК → услуга «Организация в Cloud Director» → название услуги | `organ` |
| `vdc_network_provider` | ЛК → создание vDC → «Сетевой провайдер» | `snb1` |
| `vdc_provider_vdc` | ЛК → создание vDC → «Provider VDC» | `Intel Broadwell 2.4` |
| `vdc_storage_config` | ЛК → создание vDC → доступные дисковые политики | `SATA` |
| `ip_space_name` | ЛК → карточка организации → внешние IP | `internet-ipv4-v1` |
## 3. Заполните значения
```bash
cp terraform.tfvars.example terraform.tfvars
nano terraform.tfvars
```
Файл `terraform.tfvars` выглядит так:
```hcl
# Токен из ЛК
api_token = "eyJhbGciOi..."
# Организация из ЛК (создана заранее)
organization = "organ"
# Внешние IP
ip_space_name = "internet-ipv4-v1"
ip_count = "3"
# vDC
vdc_resource_name = "fullpipe-vdc" # имя услуги в ЛК, любое
vdc_network_provider = "snb1" # ЛК → создание vDC → «Сетевой провайдер»
vdc_provider_vdc = "Intel Broadwell 2.4" # ЛК → создание vDC → «Provider VDC»
vdc_cpu_allocated = 8 # vCPU, шт.
vdc_cpu_guaranteed = 0 # резервирование vCPU, %: 0, 50 или 80
vdc_mem_allocated = 32 # RAM, ГБ
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]" # политика и размер, ГБ
# Edge (сетевой шлюз периметра)
nsxt_resource_name = "fullpipe-edge" # имя услуги в ЛК, любое
nsxt_vdc_type = "vdc" # родитель: vdc или vdcGroup
nsxt_need_enable_avi = true # балансировщик AVI (ALB)
nsxt_virtual_services_count = 3 # виртуальных сервисов AVI: 1..4 (Штурвал: не меньше 3)
nsxt_ip_addr_pool = "10.10.102.0/24" # пул адресов routed-сети, маска /24
nsxt_main_dns = "81.22.46.22" # основной DNS
nsxt_second_dns = "185.247.187.77" # второй DNS
```
Описание всех параметров — в `variables.tf`. `terraform.tfvars` с токеном никому не передавайте
и не коммитьте в git.
## 4. Выполните команды
```bash
terraform init # один раз — скачает провайдер
terraform plan # покажет, что будет создано: 4 ресурса
terraform apply # создаст (подтвердить: yes)
```
## 5. Проверьте результат
```bash
terraform output # UUID и имена созданных услуг
```
И в ЛК: появились vDC и Edge, на организации выделены внешние IP, на шлюзе включён SNAT.
Повторный `terraform plan` должен показать `No changes`.
## 6. Удаление
```bash
terraform destroy
```
Порядок обратный: SNAT выключается, квота внешних IP обнуляется, затем удаляется шлюз,
а vDC приостанавливается (данные сохраняются). **Организация не удаляется.**
## Файлы примера
| Файл | Что делает |
|---|---|
| `versions.tf` | версия Terraform и провайдера Nubes |
| `provider.tf` | подключение к API (токен, адрес) |
| `variables.tf` | все параметры с описанием |
| `vdc.tf` | виртуальный датацентр |
| `edge.tf` | сетевой шлюз периметра (Edge) |
| `modifiers.tf` | внешние IP на организации + SNAT на шлюзе |
| `outputs.tf` | UUID и имена созданных услуг |
| `terraform.tfvars.example` | шаблон значений (копируется в `terraform.tfvars`) |
Подробнее про два последних ресурса — на странице
[«Ресурсы-модификаторы (IP организации, SNAT)»](../modifiers/org_ip_and_snat.md).
+2
View File
@@ -60,3 +60,5 @@ nav:
- Глоссарий: 30_registry/guides/glossary.md - Глоссарий: 30_registry/guides/glossary.md
- Проверенные примеры: - Проверенные примеры:
- PostgreSQL: curated/postgres/pg_user_db.md - PostgreSQL: curated/postgres/pg_user_db.md
- Пайплайн vDC → Edge → IP → SNAT: curated/pipeline/vdc_edge_ip_snat.md
- Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md
+18 -1
View File
@@ -8,11 +8,28 @@ package jsonutil
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
) )
// uuidAnywhereRegex — UUID-подобная подстрока внутри произвольного текста
// (например, внутри JSON-строки). Регистр не учитывается.
var uuidAnywhereRegex = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
// LowercaseUUIDsInText приводит все UUID-подобные подстроки к lowercase.
//
// Зачем: облако отдаёт один и тот же UUID то в lowercase (vdcUid), то в UPPERCASE
// (nsxtUid у кластера Штурвала), а пользователь может написать в config любой
// регистр. Сравнение plan vs actual обязано игнорировать регистр UUID.
func LowercaseUUIDsInText(value string) string {
if !uuidAnywhereRegex.MatchString(value) {
return value
}
return uuidAnywhereRegex.ReplaceAllStringFunc(value, strings.ToLower)
}
// JSONStringsEquivalent compares JSON strings ignoring whitespace and map key order. // JSONStringsEquivalent compares JSON strings ignoring whitespace and map key order.
func JSONStringsEquivalent(a, b string) bool { func JSONStringsEquivalent(a, b string) bool {
na, oka := normalizeJSONIfPossible(a) na, oka := normalizeJSONIfPossible(a)
@@ -107,7 +124,7 @@ func normalizeJSONScalarsToStrings(v interface{}) interface{} {
} }
return out return out
case string: case string:
return val return LowercaseUUIDsInText(val)
case float64: case float64:
return strconv.FormatFloat(val, 'g', -1, 64) return strconv.FormatFloat(val, 'g', -1, 64)
case bool: case bool:
@@ -0,0 +1,67 @@
package jsonutil
import "testing"
// FIX(uuid-case) регрессия: облако отдаёт один и тот же UUID то в lowercase (vdcUid),
// то в UPPERCASE (nsxtUid у кластера Штурвала). Сравнение plan vs actual должно
// игнорировать регистр UUID — и для одиночного значения, и внутри JSON.
func TestJSONStringsEquivalentUUIDCase(t *testing.T) {
cases := []struct {
name string
a string
b string
want bool
}{
{
name: "uuid внутри JSON: разный регистр — эквивалентно",
a: `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","clusterName":"shturval-dev-00"}`,
b: `{"clusterName":"shturval-dev-00","nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6"}`,
want: true,
},
{
name: "вложенный JSON и смешанный регистр",
a: `{"a":{"b":["2C37FED1-E8F8-4A84-8434-7851C7C8B5D6"]}}`,
b: `{"a":{"b":["2c37fed1-e8f8-4a84-8434-7851c7c8b5d6"]}}`,
want: true,
},
{
name: "разные UUID — не эквивалентно",
a: `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6"}`,
b: `{"nsxtUid":"86a01033-adac-424f-8296-aa10a001d6ed"}`,
want: false,
},
{
name: "разные значения не-UUID — не эквивалентно",
a: `{"clusterName":"shturval-dev-00"}`,
b: `{"clusterName":"shturval-dev-01"}`,
want: false,
},
{
name: "числа и bool остаются различимыми",
a: `{"exVip":true,"count":3}`,
b: `{"exVip":true,"count":"3"}`,
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := JSONStringsEquivalent(tc.a, tc.b); got != tc.want {
t.Fatalf("JSONStringsEquivalent(%s, %s) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
func TestLowercaseUUIDsInText(t *testing.T) {
in := `{"nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6","name":"internet-ipv4-v1"}`
want := `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","name":"internet-ipv4-v1"}`
if got := LowercaseUUIDsInText(in); got != want {
t.Fatalf("LowercaseUUIDsInText() = %s, want %s", got, want)
}
// Текст без UUID не меняется (в т.ч. имя ipSpace с дефисами и цифрами).
plain := `{"ipSpaceName":"internet-ipv4-v1","qosProfile":"QoS-100Mbit"}`
if got := LowercaseUUIDsInText(plain); got != plain {
t.Fatalf("LowercaseUUIDsInText() изменил текст без UUID: %s", got)
}
}
@@ -2,5 +2,15 @@ package provider
import _ "embed" import _ "embed"
// operation_timeouts.json — ДЕФОЛТНЫЙ конфиг таймаутов операций для сборки из
// исходников (без него падает go build / go test в provider/).
//
// Это НЕ сгенерированный артефакт и он СОЗНАТЕЛЬНО лежит в git:
// при релизе скрипт TOOLS/scripts/03_build_and_upload_provider.sh копирует
// провайдер во временный каталог и ПОДМЕНЯЕТ там этот файл профильным
// (TOOLS/config/<profile>/operation_timeouts.json) — рабочее дерево не меняется.
//
// Менять значения для релиза нужно в TOOLS/config/<profile>/, а не здесь.
//
//go:embed operation_timeouts.json //go:embed operation_timeouts.json
var operationTimeoutsConfigEmbedded []byte var operationTimeoutsConfigEmbedded []byte
+4
View File
@@ -182,6 +182,10 @@ func (p *NubesProvider) Configure(ctx context.Context, req provider.ConfigureReq
func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource { func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource {
resources := resources_gen.AllResources() resources := resources_gen.AllResources()
resources = append(resources, resources_core.NewServiceOperationResource) resources = append(resources, resources_core.NewServiceOperationResource)
// Ресурсы-модификаторы для операций, которых нет в create-схеме ресурсов-инстансов.
// Организация и шлюз создаются вручную в ЛК, поэтому адресуются по uid, а не ссылкой на ресурс.
resources = append(resources, resources_core.NewOrgIpAllocationResource)
resources = append(resources, resources_core.NewNsxtSnatResource)
return resources return resources
} }
@@ -17,6 +17,8 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-nubes/internal/core/jsonutil"
) )
// jsonNormalizePlanModifier реализует planmodifier.String. // jsonNormalizePlanModifier реализует planmodifier.String.
@@ -49,5 +51,7 @@ func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planm
// Невалидный JSON — оставляем значение без изменений, не прерываем планирование. // Невалидный JSON — оставляем значение без изменений, не прерываем планирование.
return return
} }
resp.PlanValue = types.StringValue(buf.String()) // FIX(uuid-case): UUID-подстроки — к lowercase, чтобы план не расходился с
// состоянием только из-за регистра UUID (облако отдаёт их в разных регистрах).
resp.PlanValue = types.StringValue(jsonutil.LowercaseUUIDsInText(buf.String()))
} }
@@ -0,0 +1,258 @@
package resources_core
import (
"context"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &NsxtSnatResource{}
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
//
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
//
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
type NsxtSnatResource struct {
client *core.UniversalClient
}
type NsxtSnatModel struct {
ID types.String `tfsdk:"id"`
NsxtUID types.String `tfsdk:"nsxt_uid"`
IpSpaceName types.String `tfsdk:"ip_space_name"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// noNeededIpSpace — каноническое значение «SNAT не нужен».
const noNeededIpSpace = "no-needed"
func NewNsxtSnatResource() resource.Resource {
return &NsxtSnatResource{}
}
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
}
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
"Значение `no-needed` выключает SNAT.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"nsxt_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ip_space_name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
"`ipSpaceName = \"no-needed\"`).",
},
},
}
}
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если ключа ещё нет
// (SNAT ни разу не включали, HAR fresh-create) — оставляем текущее значение state.
if raw, ok := live["ipSpaceName"]; ok && strings.TrimSpace(raw) != "" {
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
}
state.ID = types.StringValue(nsxtUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
// Реальная ошибка API (не «шлюза нет») — нельзя молча терять SNAT: ресурс уйдёт из state,
// а SNAT останется включённым.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
)
return
}
unlock := r.client.LockInstance(nsxtUID)
defer unlock()
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
"ipSpaceName": noNeededIpSpace,
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"SNAT выключен",
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
)
}
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
}
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
uid := strings.TrimSpace(nsxtUID.ValueString())
if uid == "" {
return fmt.Errorf("nsxt_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
// Пустую строку молча подменять нельзя (скрытое поведение + риск вечного diff).
// Выключение SNAT — явное каноническое значение "no-needed".
value := strings.TrimSpace(ipSpaceName.ValueString())
if value == "" {
return fmt.Errorf("ip_space_name не может быть пустым: укажите имя ipSpace или %q для выключения SNAT", noNeededIpSpace)
}
unlock := r.client.LockInstance(uid)
defer unlock()
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
// а не с live-состоянием инстанса — можно ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"ipSpaceName": value,
})
}
@@ -0,0 +1,413 @@
package resources_core
import (
"context"
"encoding/json"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &OrgIpAllocationResource{}
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
//
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
// и адресуется здесь по uid.
//
// Семантика операции — replace всего массива: переданное значение полностью заменяет
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
type OrgIpAllocationResource struct {
client *core.UniversalClient
}
type OrgIpAllocationModel struct {
ID types.String `tfsdk:"id"`
Organization types.String `tfsdk:"organization"`
VIPConfigure types.String `tfsdk:"vip_configure"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
type vipAllocation struct {
Name string
Count string
}
func NewOrgIpAllocationResource() resource.Resource {
return &OrgIpAllocationResource{}
}
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
}
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"organization": schema.StringAttribute{
Required: true,
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
"или её UUID.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"vip_configure": schema.StringAttribute{
Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
"`count=0` по каждому элементу).",
},
},
}
}
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
// Организации больше нет — ресурс тоже не нужен.
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
raw, ok := live["vIPConfigure"]
if ok {
liveItems, parseErr := parseVipConfigure(raw)
if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return
}
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
if !vipAllocationsEqual(liveItems, stateItems) {
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
}
}
state.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
// Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state,
// а выделенные IP останутся висеть.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
)
return
}
unlock := r.client.LockInstance(orgUID)
defer unlock()
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
items := []vipAllocation{}
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
)
return
}
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
zero := make([]vipAllocation, 0, len(items))
for _, item := range items {
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
}
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(zero),
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"Квота IP обнулена",
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
)
}
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
}
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
// Резолв делает клиент — тем же путём, что сгенерированный nubes_vc_vdc
// (core.ResolveRefSvcParamValue, сравн. 21_vc_vdc_resource.go).
func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, organization types.String) (string, error) {
if r.client == nil {
return "", fmt.Errorf("клиент не инициализирован")
}
raw := strings.TrimSpace(organization.ValueString())
if raw == "" {
return "", fmt.Errorf("organization обязателен")
}
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
if err != nil {
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
}
resolved = strings.TrimSpace(resolved)
if resolved == "" {
return "", fmt.Errorf("организация %q не найдена", raw)
}
return resolved, nil
}
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID)
if uid == "" {
return fmt.Errorf("organization обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
items, err := parseVipConfigure(vipConfigure.ValueString())
if err != nil {
return err
}
if len(items) == 0 {
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
}
unlock := r.client.LockInstance(uid)
defer unlock()
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(items),
})
}
// parseVipConfigure разбирает значение параметра vIPConfigure.
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
func parseVipConfigure(raw string) ([]vipAllocation, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
}
out := make([]vipAllocation, 0, len(items))
for _, item := range items {
name := ""
if v, ok := item["name"]; ok && v != nil {
name = strings.TrimSpace(fmt.Sprint(v))
}
if name == "" {
continue
}
count := "0"
if v, ok := item["count"]; ok && v != nil {
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
count = parsed
}
}
out = append(out, vipAllocation{Name: name, Count: count})
}
return out, nil
}
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read,
// иначе план и state расходятся по строке — см. vipConfigureCanonical.
func formatVipConfigure(items []vipAllocation) string {
if len(items) == 0 {
return "[]"
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count))
}
return "[" + strings.Join(parts, ",") + "]"
}
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
func vipAllocationsEqual(a, b []vipAllocation) bool {
if len(a) != len(b) {
return false
}
byName := make(map[string]string, len(b))
for _, item := range b {
byName[item.Name] = item.Count
}
for _, item := range a {
count, ok := byName[item.Name]
if !ok || count != item.Count {
return false
}
}
return true
}
@@ -0,0 +1,102 @@
package resources_core
import "testing"
// Проверяем нормализацию vIPConfigure: свежая орга отдаёт `[{}]` (пустой элемент) —
// это «не выделено», а не аллокация (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md).
func TestParseVipConfigure_EmptyAndBroken(t *testing.T) {
cases := []struct {
name string
raw string
want int
}{
{"пустая строка", "", 0},
{"пустой массив", "[]", 0},
{"пустой элемент (свежая орга)", "[{}]", 0},
{"только name без count", `[{"name":"internet-ipv4-v1"}]`, 1},
{"элемент без name", `[{"count":"3"}]`, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseVipConfigure(tc.raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != tc.want {
t.Fatalf("получено %d элементов, ожидалось %d (%+v)", len(got), tc.want, got)
}
})
}
}
func TestParseVipConfigure_CountAsString(t *testing.T) {
// count у платформы может прийти и числом (в старых стейтах) — нормализуем в строку.
got, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":4}]`)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != 1 || got[0].Count != "4" {
t.Fatalf("ожидался count=\"4\", получено %+v", got)
}
}
func TestFormatVipConfigure_Canonical(t *testing.T) {
got := formatVipConfigure([]vipAllocation{{Name: "internet-ipv4-v1", Count: "3"}})
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
if got != want {
t.Fatalf("получено %q, ожидалось %q", got, want)
}
if empty := formatVipConfigure(nil); empty != "[]" {
t.Fatalf("для пустого списка ожидалось \"[]\", получено %q", empty)
}
}
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
a, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
b, err := parseVipConfigure(`[{"count":"3","name":"internet-ipv4-v1"}]`) // так отдаёт jsonencode
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if !vipAllocationsEqual(a, b) {
t.Fatal("значения должны считаться равными несмотря на порядок ключей")
}
}
func TestVipAllocationsEqual_Differences(t *testing.T) {
base, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
otherCount, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"2"}]`)
otherName, _ := parseVipConfigure(`[{"name":"internet-antiddos-v1","count":"3"}]`)
empty, _ := parseVipConfigure(`[{}]`)
if vipAllocationsEqual(base, otherCount) {
t.Fatal("разный count должен считаться разными значениями")
}
if vipAllocationsEqual(base, otherName) {
t.Fatal("разное имя ipSpace должно считаться разными значениями")
}
if vipAllocationsEqual(base, empty) {
t.Fatal("пустая аллокация должна отличаться от непустой")
}
}
func TestParseVipConfigure_RoundTripIsStable(t *testing.T) {
raw := `[{"name":"internet-ipv4-v1","count":"4"}]`
items, err := parseVipConfigure(raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if again := formatVipConfigure(items); again != raw {
t.Fatalf("round-trip не стабилен: %q → %q", raw, again)
}
}
func TestParseVipConfigure_InvalidJSON(t *testing.T) {
if _, err := parseVipConfigure(`{"name":"x"}`); err == nil {
t.Fatal("ожидалась ошибка на объект вместо массива")
}
}
@@ -0,0 +1,22 @@
package resources_core
import "testing"
// FIX(uuid-case): регрессия today's bug — adopt приостановленного инстанса Штурвала
// падал с "required params mismatch ... startupConfiguration", потому что UUID
// внутри JSON сравнивался с учётом регистра (plan — lowercase из config,
// actual — UPPERCASE от API). Теперь UUID внутри JSON эквивалентны.
func TestParamsEquivalentUUIDCaseInsideJSON(t *testing.T) {
plan := `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51","clusterName":"shturval-dev-00","exVip":true}`
actual := `{"clusterName":"shturval-dev-00","exVip":true,"nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51"}`
if !paramsEquivalent(plan, actual) {
t.Fatalf("paramsEquivalent() = false, ожидалось true: регистр UUID внутри JSON не должен влиять")
}
// Реально другой nsxtUid — по-прежнему не эквивалентно.
other := `{"clusterName":"shturval-dev-00","exVip":true,"nsxtUid":"86a01033-adac-424f-8296-aa10a001d6ed","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51"}`
if paramsEquivalent(plan, other) {
t.Fatalf("paramsEquivalent() = true для другого nsxtUid — неверно")
}
}