84 Commits
Author SHA1 Message Date
Repinoid 25f339bc8a 1 2026-09-28 09:42:25 +03:00
Repinoid 5742457cc0 docs(rules): приведена стилистика copilot-instructions.md — разделы и списки вместо капслока, все правила сохранены 2026-09-28 09:41:27 +03:00
Repinoid 6b6252c42b docs(pipeline): fix vdc CPU example for VM 2026-09-28 09:40:12 +03:00
Repinoid 70b937ea2f docs(pipeline): встроена схема зависимостей сервисов 2026-09-28 09:20:25 +03:00
Repinoid bc1af681aa build(docs): скрипт 04 копирует картинки docs/diagrams (*.svg|png) в публикуемый docs_dir; README диаграмм — раздел о публикации 2026-09-28 09:20:25 +03:00
Repinoid 318b847ede docs(modifiers): квота IP — учёт внешнего адреса ВМ (count = 4), актуальная ссылка на страницу пайплайна 2026-09-28 09:19:14 +03:00
Repinoid 7d762387e7 docs(provider-behavior): vApp/ВМ в таблице ресурсов, в заморозке (suspend/adopt) и в §6-пайплайне (шаги 6-8, лимит vCPU vDC, 14 дней для vApp) 2026-09-28 09:19:00 +03:00
Repinoid 0d7b8c5310 docs(nav): пункт меню пайплайна — добавлены vApp и ВМ 2026-09-28 09:19:00 +03:00
Repinoid 2675495eef docs(pipeline): цепочка расширена vApp -> ВМ — требования (квота CPU vDC, 4-й IP, SSH-ключ), параметры ВМ и образы, внешний доступ, раздел 6 с чек-листом, destroy для vApp/ВМ 2026-09-28 09:18:36 +03:00
Repinoid 034096d16d chore: бэкап FPipeGmail перед добавлением ВМ (без tfvars/tfstate — они в .gitignore) + HISTORY по vpn-transit-213 2026-09-28 08:04:52 +03:00
Repinoid 15cd369ec3 feat(fpipeline): ВМ в пайплайн FPipeGmail — самодостаточный vm.tf (vApp 26 + ВМ 28), внешний IP через общий SNAT, suspend_on_destroy 2026-09-28 07:54:50 +03:00
Repinoid 2fd9ef8aba docs(resume): правки по фактам — ответы по vApp/ВМ (обязательные параметры, suspend, образы, сеть), реальное состояние аккаунтов, исправлено ложное наблюдение про список инстансов (ключ results) 2026-09-27 19:20:22 +03:00
Repinoid ef22e78d8a docs(resume): резюме для нового чата — добавление ВМ (vApp 26 + ВМ 28) в пайплайн; что уже сделано, состояние стендов/облака, точки входа, развилки 2026-09-27 19:17:08 +03:00
Repinoid a366f47eff docs: move diagram artifacts to docs/diagrams, add vApp-IP precondition link and creation guide 2026-09-26 19:30:41 +03:00
Repinoid 6791e8fb5f docs: add styled infrastructure dependency diagram (generator + png + svg) 2026-09-26 19:13:57 +03:00
Repinoid ff38352bfc docs: remove duplicated SNAT label from edge node in flow diagram 2026-09-26 19:06:01 +03:00
Repinoid dc837e8e1a docs: verify cloud service dependency chains against YAML, add VM/Shurval checklists, redraw diagram 2026-09-26 18:49:17 +03:00
Repinoid 223b0ccdca docs: fix compute pool link to originate from vDC 2026-09-26 18:33:01 +03:00
Repinoid ea725bd8b9 docs: add infrastructure services flow diagram (mmd, svg, png) 2026-09-26 08:22:03 +03:00
Repinoid 48009583a6 Add September 25 Terraform backups 2026-09-26 07:20:59 +03:00
Repinoid e72eb75d10 stand(FPipeGmail): свои имена Штурвала — shturval-dev1 / shturval-dev-01 (занятое имя кластера не подошло) 2026-09-25 13:31:33 +03:00
Repinoid 8d25de9e79 docs: страница «Как работает провайдер (отличия от Terraform)» в навигации + ссылка на страницу пайплайна vDC → Edge → IP → SNAT → Штурвал 2026-09-25 09:59:56 +03:00
Repinoid f8d64948c8 docs(curated): страница «vDC → Edge → IP → SNAT → Штурвал» (требования, заморозка, проверка результата) + nav + HISTORY 2026-09-25 09:47:33 +03:00
Repinoid e20c22e0cb stand(FPipeGmail): организация kontra (аккаунт tazetdinovn@gmail.com) вместо устаревшей kontora 2026-09-25 08:53:33 +03:00
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
97 changed files with 8807 additions and 93 deletions
+31 -16
View File
@@ -1,16 +1,31 @@
НИКАКОЙ САМОДЕЙТЕЛЬНОСТИ !!! делать ТОЛЬКО ТО НА ЧТО ПОЛУЧЕНО РАЗРЕШЕНИЕ !!!!
НИКАКИХ ДОГАДОК !!! ЕСТь сомнения - СПРОСИ !!!
НИКОГДА НЕ ДЕЛАЙ ПРЕДПОЛОЖЕНИЙ !!!
ВСЕГДА СПРАШИВАЙ, ЕСЛИ НЕ УВЕРЕН !!!
НИКОГДА НЕ ИГНОРИРУЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
ВСЕГДА ПОДТВЕРЖДАЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
НИКОГДА НЕ ИЗМЕНЯЙ ИНСТРУКЦИИ БЕЗ РАЗРЕШЕНИЯ !!!
ВСЕГДА СОБЛЮДАЙ ПОРЯДОК И ПОСЛЕДОВАТЕЛЬНОСТЬ В ИНСТРУКЦИЯХ !!!
НИКОГДА НЕ ПРЕВЫШАЙ СВОИ ПОЛНОМОЧИЯ !!!
ВСЕГДА СОБЛЮДАЙ БЕЗОПАСНОСТЬ И КОНФИДЕНЦИАЛЬНОСТЬ !!!
НИКОГДА НЕ ПЕРЕДАВАЙ СЕКРЕТЫ ИНТЕРНЕТУ БЕЗ РАЗРЕШЕНИЯ !!!
НЕ ВЫЗЫВАТЬ ДРУГИЕ АГЕНТЫ БЕЗ РАЗРЕШЕНИЯ !!!
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
# Правила работы в этом репозитории
## Разрешения и самодеятельность
- **Никакой самодеятельности**: делать только то, на что получено разрешение.
- Полученные инструкции **не игнорировать**: соблюдать их и подтверждать.
- Соблюдать порядок и последовательность инструкций.
- Не превышать свои полномочия.
- Соблюдать безопасность и конфиденциальность.
- Не изменять инструкции без разрешения.
- Не вызывать другие агенты без разрешения.
- Не передавать секреты в интернет без разрешения.
## Сомнения и вопросы
- **Никаких догадок**: есть сомнения — спроси.
- Никогда не делать предположений и не действовать по догадкам.
- Всегда спрашивать, если не уверен.
- Если уверенности в распоряжении нет на 100 % — остановиться, спросить снова и подтвердить, что имел в виду пользователь. Не гадать.
- Перепроверять всё несколько раз.
## Коммиты и бэкапы
- Коммитить после каждой правки — чтобы зафиксировать текущее состояние и не потерять изменения.
- Сообщения коммитов — осмысленные, отражающие суть изменений.
- Всегда сохранять резервные копии важных файлов перед внесением изменений.
## Общий принцип
- Соблюдать инструкции, давать подтверждения и не делать самостоятельных изменений.
- Не полагаться на память — всегда проверять актуальность инструкций.
+6
View File
@@ -6,6 +6,9 @@
.terraform.lock.hcl
# === 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/internal/resources_gen/
@@ -30,6 +33,9 @@ provider/generated/
*.exe
*.test
*.out
# Локально собранный провайдер под dev_overrides (см. TMP/terraformrc.dev)
TMP/devbin/
terraform-provider-nubes
# === Build artifacts (generated by devops scripts) ===
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-dev1"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-01"
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 = "kontra"
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"
}
}
}
+183
View File
@@ -0,0 +1,183 @@
# =============================================================================
# Виртуальная машина внутри vApp — услуги 26 (vApp) и 28 (ВМ)
#
# Всё, что относится к ВМ, лежит ТОЛЬКО в этом файле: переменные, их значения
# по умолчанию, оба ресурса и выводы. Чтобы выключить ВМ — удалить или
# закомментировать этот файл (по аналогии с shturval.tf).
#
# Место в цепочке:
# орга (вручную в ЛК) → vDC (21) → Edge (22) → внешние IP → SNAT
# → [ vApp (26) → ВМ (28) ] → Штурвал (150)
#
# Зависимости (из манифестов услуг, сгенерированные ресурсы):
# vApp (26) — nubes_vapp: требует vdc_uid (21) и nsxt_uid (22)
# ВМ (28) — nubes_vc_vm_v3: требует vapp_uid (26)
#
# Внешний доступ: ВМ публикуется за общим SNAT эджа (same_snat = false), для
# этого ipSpace должен быть выделен на организации и включён как SNAT
# (см. modifiers.tf). Нужен ВЫДЕЛЕННЫЙ внешний адрес — same_snat = true.
# ipSpace для ВМ берём тот же, что у SNAT (var.ip_space_name).
#
# Режим destroy: у обеих услуг операция delete требует предварительного
# suspend, поэтому по умолчанию suspend_on_destroy = true («заморозка»).
# Полное удаление vApp возможно только через 14 дней после suspend.
# =============================================================================
# --- Переменные vApp ---
variable "vapp_resource_name" {
type = string
default = "fullpipe-vapp"
description = "Имя услуги «Виртуальный каталог ВМ (vApp)» в ЛК"
}
variable "vapp_name" {
type = string
default = "fullpipe-vapp-01"
description = "Имя vApp. Маска ^[a-z0-9][a-z0-9.-]{3,61}[a-z0-9]$, уникально в организации; участвует в DNS-имени ВМ. НЕ оставлять дефолтом платформы."
}
# --- Переменные ВМ ---
variable "vm_resource_name" {
type = string
default = "fullpipe-vm-01"
description = "Имя услуги «Виртуальная машина» в ЛК"
}
variable "vm_name" {
type = string
default = "web01"
description = "Имя ВМ. Маска ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$. Определяет имя NSX-T IP Set: {vapp_name}-{vm_name}"
}
variable "vm_image" {
type = string
default = "Ubuntu_22-20G"
description = "Образ ОС. Доступные значения: RockyLinux_9-16G-cloudinit, Ubuntu_22-20G, Debian_13-20G. Не изменяется после создания"
}
variable "vm_cpu" {
type = number
default = 2
description = "vCPU (1..64), шт"
}
variable "vm_ram" {
type = number
default = 2
description = "RAM (1..256), GB"
}
variable "vm_disk" {
type = number
default = 20
description = "Дополнительный диск, GB (основной диск зависит от образа)"
}
variable "vm_user_login" {
type = string
default = "ubuntu"
description = "Учётка SSH. Не изменяется после создания"
}
variable "vm_user_public_key" {
type = string
description = "Публичная часть SSH-ключа в формате OpenSSH. Не изменяется после создания. Значение — в terraform.tfvars"
}
variable "vm_access_port_list" {
type = list(object({
port = string
type = string
}))
default = [
{ port = "22", type = "tcp" }
]
description = "Белый список портов для доступа извне; type: tcp | udp | all"
}
variable "vm_access_ip_list" {
type = list(string)
default = ["0.0.0.0/0"]
description = "Белый список адресов, которым разрешён доступ к ВМ. Требует выделенного внешнего IP"
}
variable "vm_same_snat" {
type = bool
default = false
description = "false — публикация за общим SNAT эджа; true — за выделенным внешним IP услуги"
}
# --- vApp (услуга 26) ---
resource "nubes_vapp" "vapp" {
resource_name = var.vapp_resource_name
vapp_name = var.vapp_name
vdc_uid = nubes_vc_vdc.vdc.id # ref 21 — вычислительная инфраструктура
nsxt_uid = nubes_vc_nsxt.edge.id # ref 22 — сеть/маршрутизация
# «Заморозка»: destroy переводит vApp в suspend (delete требует suspend).
suspend_on_destroy = true
# Повторный apply усыновляет существующий vApp, а не падает с
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ».
adopt_existing_on_create = true
# vApp требует готовую сеть (ipSpace на организации + SNAT на эдже).
depends_on = [nubes_vc_nsxt_snat.snat]
}
# --- ВМ (услуга 28) ---
resource "nubes_vc_vm_v3" "vm" {
resource_name = var.vm_resource_name
vm_name = var.vm_name
vapp_uid = nubes_vapp.vapp.id # ref 26 — ВМ размещается в vApp
image_vm = var.vm_image
vm_cpu = var.vm_cpu
vm_ram = var.vm_ram
vm_disk = var.vm_disk
user_login = var.vm_user_login
user_public_key = var.vm_user_public_key
# Внешний доступ
ip_space_name = var.ip_space_name # тот же ipSpace, что у SNAT эджа
same_snat = var.vm_same_snat
access_port_list = jsonencode(var.vm_access_port_list)
access_ip_list = jsonencode(var.vm_access_ip_list)
# «Заморозка»: destroy переводит ВМ в suspend.
suspend_on_destroy = true
adopt_existing_on_create = true
# ВМ создаётся платформой долго — поднимаем таймаут ожидания.
operation_timeout = "15m"
}
# --- Выводы ---
output "vapp_id" {
description = "UID созданного vApp (услуга 26)"
value = nubes_vapp.vapp.id
}
output "vapp_name" {
description = "Имя vApp"
value = nubes_vapp.vapp.vapp_name
}
output "vm_id" {
description = "UID созданной ВМ (услуга 28)"
value = nubes_vc_vm_v3.vm.id
}
output "vm_state_flat" {
description = "Плоский state ВМ — IP-адреса, статус и т.д."
value = nubes_vc_vm_v3.vm.state_out_flat
}
+8
View File
@@ -17,4 +17,12 @@ resource "nubes_vc_nsxt" "edge" {
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
}
+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)"
}
# --- Модификаторы (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"
+1 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
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,43 @@
# 2026-09-25 — Штурвал в примере `fullpipe_chain` + страница документации
## Что сделано
Примеры (`tf_examples`, отдельный репозиторий `https://gitea.services.ngcloud.ru/Nail/tf_examples.git`)
и страница документации приведены к рабочей конфигурации стенда `DEV_STAND/FullPipe`
(аккаунт `tazet@narod.ru`) — теперь цепочка полная: **vDC → Edge → внешние IP → SNAT → Штурвал**.
| Файл | Изменение |
|---|---|
| `tf_examples/fullpipe_chain/shturval.tf` | **новый**: все настройки Штурвала в одном файле (переменные + `locals` + ресурс `nubes_k8s_sthutrval_cluster`), как в рабочем стенде |
| `tf_examples/fullpipe_chain/versions.tf` | провайдер `2.0.21` → `2.0.23` (последняя dev) |
| `tf_examples/fullpipe_chain/edge.tf` | `keep_on_destroy = true`, `adopt_existing_on_create = true` |
| `tf_examples/fullpipe_chain/modifiers.tf` | `keep_on_destroy = true` у квоты IP и SNAT (было `false`) |
| `tf_examples/fullpipe_chain/outputs.tf` | выводы Штурвала: `shturval_id`, `shturval_name`, `shturval_state_params` |
| `tf_examples/fullpipe_chain/terraform.tfvars.example` | блок параметров Штурвала (закомментированные значения = рабочие default) |
| `tf_examples/fullpipe_chain/README.md`, `tf_examples/README.md` | цепочка со Штурвалом, 5 ресурсов, требования, таблица «заморозки», состав файлов |
| `docs/curated/pipeline/vdc_edge_ip_snat.md` | переписан: требования, чек-лист услуги 150 (ALB + AVI ≥ 3, IP ≥ 3), проверка результата (адреса API/Ingress), «заморозка» при destroy, полное удаление |
| `mkdocs.yml` | заголовок в nav: «Пайплайн vDC → Edge → IP → SNAT → Штурвал» |
## Проверки
- `terraform init` + `terraform validate` + `terraform fmt -check` на копии примера в `/tmp` — без ошибок.
- `terraform plan` (копия в `/tmp`, организация `kontra`, токен `secrets/dev.token`) — `5 to add, 0 change, 0 destroy`, ошибок нет.
- Копия для проверки делалась в `/tmp`, **не** в `tf_examples/`: там нет `.gitignore` для `.terraform/`, и служебные файлы уехали бы в публичный репозиторий.
## Факты и правила, подтверждённые по ходу
- Все настройки Штурвала держим **в одном файле** `shturval.tf` (переменные + ресурс): чтобы выключить Штурвал — удалить файл.
- Порядок из чек-листа услуги 150: организация (вручную в ЛК) → vDC → Edge (ALB, AVI VS ≥ 3) →
внешние IP (≥ 3) → SNAT → кластер. Минимум ноды: 1 + 1 по 4 vCPU / 8 ГБ / 50 ГБ.
- `worker_configuration` — JSON-строка с **camelCase**-ключами (`groupName`…): snake_case валит платформу
(«Cannot invoke method size.split() on null object»).
- «Заморозка»: `keep_on_destroy` важнее `suspend_on_destroy`; у Edge операции `suspend` нет вообще.
- Публикация доков: `TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/dev <версия>`
(версию надо передавать аргументом — в `profile.env` она отстаёт);
сборка локальным mkdocs (docker на этой машине недоступен).
## Ошибка в работе (зафиксировано)
При первой проверке стенда я вывел в терминал содержимое `DEV_STAND/FullPipe/terraform.tfvars` —
файл содержит живой `api_token`. В git файл не попадает (`*.tfvars` в `.gitignore`), но токен оказался
в логе вывода. Правило: секреты из `.tfvars` не печатать, сравнивать по хешу/маскировать.
@@ -0,0 +1,96 @@
# VPN transit via VM 213 and Vultr
Date: 2026-09-27 to 2026-09-28
## Goal
Provide access from Russian residential/mobile networks to services restricted by Russian network filtering, while retaining the existing foreign egress on Vultr.
## Verified network facts
- Test host `3060`: `46.39.251.163`, connection from Khimki / Iskratelecom.
- Transit VM `213`: `5.172.178.213`, public egress observed as `5.172.178.65`; hosted in NUBES data centre.
- Vultr addresses: primary `95.179.252.111`; secondary `104.238.177.67`.
- `3060 -> 213`: ICMP approximately 3 ms, 0% loss.
- `213 -> Vultr`: ICMP approximately 34 ms, 0% loss; HTTPS response returned in about 0.07-0.11 s.
- Direct `213 -> Vultr` test file transfer: 10 MiB in 1.59 s, about 6.27 MiB/s / 50.2 Mbit/s.
- Direct `3060 -> Vultr` test file transfer timed out / was throttled.
- Direct `213 -> OVH proof endpoint`: 10 MiB in 1.18 s, about 8.5 MiB/s.
- Direct access from `213` to YouTube and Telegram failed with `HTTP=000` and timeout/SSL errors, while OVH and Google returned HTTP 200. Therefore a foreign egress remains required for those services.
## Persistent changes on VM 213
- Created backup:
- `/etc/nginx/sites-available/check.kube5s.ru.bak_vpn`
- Modified:
- `/etc/nginx/sites-available/check.kube5s.ru`
- Added an Nginx `/ws` reverse-proxy location with:
- upstream `https://95.179.252.111:443`
- SNI `vipien.kube5s.ru`
- upstream Host header `vipien.kube5s.ru`
- WebSocket upgrade headers
- 3600-second proxy timeouts
- Ran `nginx -t` successfully and reloaded Nginx.
- Existing unrelated Nginx warnings about duplicate `contracts.kube5s.ru` server names remained.
## Persistent/previously existing changes on Vultr
The following configuration was read or used during validation:
- `/etc/nginx/conf.d/vipien.conf`: TLS/WebSocket endpoint for `vipien.kube5s.ru`.
- `/etc/v2ray-agent/xray/conf/08_VLESS_ws_inbound.json`: VLESS WebSocket inbound on `127.0.0.1:10086`, path `/ws`.
- `/etc/systemd/system/hysteria-server.service`: Hysteria service was stopped and disabled; it was not changed in this work.
- Xray service was confirmed active.
- Nginx service was confirmed active.
- Cloudflared tunnel configuration was inspected earlier, but it is not used by the final working route.
- A temporary 10 MiB test file was created on Vultr and removed after testing.
## Temporary files on test VM 3060
The following temporary client files were created under `/tmp/xray-test/` for validation and are not repository files:
- `client-cf.json`
- `client-213.json`
- `client-directip.json`
- temporary log/test artifacts where applicable
The files contained test Xray client configurations. They were used only to verify the route from `3060`; no permanent system service was installed there.
## Final tested route
`client in Russia -> 5.172.178.213:443 -> Nginx WebSocket proxy -> 95.179.252.111:443 -> Xray -> Internet`
Final test from `3060` through the route:
- observed outbound IP: `95.179.252.111`
- 10 MiB OVH download: 1.76-1.91 s
- measured speed: approximately 5.5-6.0 MiB/s
## Final client parameters
- Address: `5.172.178.213`
- Port: `443`
- UUID: existing UUID used by the Vultr Xray inbound
- TLS SNI: `check.kube5s.ru`
- WebSocket path: `/ws`
- WebSocket Host: `vipien.kube5s.ru`
The final direct-IP test used Xray 26.3.27. The client-side `allowInsecure` option was not used because this Xray version reports that the option was removed.
## Secondary Vultr IP
Before removal, the Nginx upstream on VM 213 was switched from `104.238.177.67` to `95.179.252.111`. A post-switch end-to-end test succeeded, with outbound IP `95.179.252.111` and approximately 6.0 MiB/s.
No Vultr IP deletion was performed in this work. The secondary address was only confirmed as no longer referenced by the transit configuration.
## Scope audit
- No repository source/configuration files were edited before this record.
- `git status` was clean before this documentation file was created.
- This documentation file is the only workspace file created by the current documentation action.
- Server-side files were changed on VM 213 and earlier on Vultr; temporary test files were also created on VM 3060.
- No commit was created for this record.
## Important limitations
The measurements prove the route worked at test time. They do not guarantee permanent availability: NUBES, Vultr, upstream providers, or network filtering policy can change independently.
@@ -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
```
@@ -0,0 +1,257 @@
# CHAT RESUME — добавляем ВМ в пайплайн (состояние на 2026-09-27)
> Цель файла: новый чат стартует **без уточняющих вопросов** — здесь всё, что уже сделано,
> что где лежит, что проверено на живых стендах и что решать по ВМ.
> Репозиторий: `/home/naeel/TF/tf_provider`, ветка `master`.
> Remote: `https://gitea.services.ngcloud.ru/terraform/tf_provider.git`.
> Репозиторий примеров: `tf_examples/` (отдельный git) → `https://gitea.services.ngcloud.ru/Nail/tf_examples.git`.
---
## 0. Где что лежит (точки входа)
| Тема | Путь |
|---|---|
| Предыдущее резюме (freeze/adopt, состояние Штурвала) | `NOTES/40_chat_summaries/CHAT_RESUME_2026-09-25_shturval_freeze_state.md` |
| Разбор диагностики + дизайн «заморозки» | `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md` |
| Зависимости сервисов DEV + чек-листы Штурвал/ВМ | `docs/60_strategy/dev_stand_service_dependencies.md` |
| Диаграмма зависимостей + генератор | `docs/diagrams/README.md`, `docs/diagrams/infra_services_flow.mmd`, `render_infra_diagram.py` |
| Страница для пользователя: поведение провайдера | `docs/30_registry/guides/provider-behavior.md` |
| Страница-инструкция пайплайна | `docs/curated/pipeline/vdc_edge_ip_snat.md` |
| Пример в репе примеров | `tf_examples/fullpipe_chain/` (в т.ч. `shturval.tf`) |
| Регламент публикации доков | `DOCS_PIPELINE/README.md`, `TOOLS/scripts/04_build_and_publish_docs.sh`, `scripts/publish-docs.sh` |
| Инструкции «как сделать руками» | `HOW_TO/README.md` (индекс), `HOW_TO/howitwasdone.md`, `HOW_TO/DEVOPS_BUILD_PIPELINE.md` |
| Версии провайдеров по стендам (источник правды) | `VERSIONS.md` |
| Память (репозиторий) | `/memories/repo/shturval-destroy-freeze.md`, `/memories/repo/registry-versions.md` |
| **ВМ: старые материалы** | `docs/20_discovery/vm_service.md`, `vm-ui-verification.md`, `vm_provisioning_failures.md`, `vapp-service.md`, `docs/40_analysis/vm_creation_failure_analysis.md`, `docs/50_history/04_vm_hang_fix_and_500_error.md`, `docs/50_history/12_vm_resource_hardening.md`, `docs/50_history/23_vapp_uid_inconsistency_displayname_resolve_bug.md` |
| **ВМ: старое резюме (ЛЕГАСИ)** | `NOTES/40_chat_summaries/CHAT_RESUME_PLAN_VM.md` — другой репозиторий (`/home/naeel/terra`), ресурс `nubes_vm`, пути неактуальны; только как история |
| **ВМ: спеки услуг** | `generated/dev/resources_yaml/26_vapp.yaml` (услуга 26, vApp), `generated/dev/resources_yaml/28_vc_vm_v3.yaml` (услуга 28, ВМ) |
| Референс от платформы (Cloud Director) | каталог `!/` в корне: `vmware_org.tf`, `vdc.tf`, `network.tf.tmpl` (цепочка ipSpace → providerGateway → providerVdc) |
| Стенды | `DEV_STAND/FullPipe/` (проверенный, аккаунт `tazet@narod.ru`), `DEV_STAND/FPipeGmail/` (аккаунт `tazetdinovn@gmail.com`, организация `kontra`) |
---
## 1. Что уже сделано (только факты, по коммитам)
**21–24.09 — ресурсы-модификаторы и релизы 2.0.18 → 2.0.23**
- `nubes_vc_org_ip_allocation` (modify `vIPConfigure`) и `nubes_vc_nsxt_snat` (modify `ipSpaceName`, обратный `no-needed`) — генератор + код + тесты (`22cf259`, `80d82a1`, `73a7459`, `4b497e6`, `ba6c4f5`, `1236c59`).
- Ресурс аллокации принимает **имя** организации (резолв в UUID) — `5bd197f`.
- Убран plan-modifier, менявший пользовательское значение (Terraform: planned value must match config) — `807dfde`.
**24.09 — Штурвал: adopt + «заморозка»**
- Всё про Штурвал собрано в один файл `shturval.tf` (`b8adeb6`); правильный сервис — **150**, не 148 (`1a90c77`, `d98f603`).
- `worker_configuration` — JSON с **camelCase** (`groupName`, `sizingPolicy`, `sizingDisk`, `labelDeck`) — snake_case валит платформу (`72d5771`).
- `adopt_existing_on_create = true` для кластера (`57abb7b`).
- Реализован **третий режим destroy** `keep_on_destroy` → `state_only` для всех instance-ресурсов + предупреждения в Delete (`22c6c83`), релиз **2.0.22** (`c29df21`).
- Конфиг стенда переведён в «заморозку» (`40aef87`): квота IP/SNAT/эдж — `keep_on_destroy = true`, кластер — `suspend_on_destroy = true`, у vDC — `suspend` + adopt.
- Живая проверка `destroy` = заморозка: `5 destroyed`, кластер и vDC → `suspended`, эдж/SNAT/квота не тронуты (`c9d7345`).
**24.09 — фикс регистра UUID, релиз 2.0.23**
- Adopt suspended-инстанса падал на регистре UUID внутри JSON (`startupConfiguration`: план `2c37fed1-…` против живого `2C37FED1-…`). Добавлены `jsonutil.LowercaseUUIDsInText`, нормализация строк внутри JSON, `JsonNormalize()` приводит UUID-подстроки к lowercase; покрыто тестами (`621280a`), релиз **2.0.23** (`208d97e`, `03fff05`).
**24–25.09 — документация**
- Новая страница «Как работает провайдер Nubes: поведение и отличия от канонического Terraform» (`aaf87d9`, читаемость `c808a3b`): §1 суть, §2 ресурсы, §3 отличия от Terraform, §4 CRUD, §5 delete/suspend/state_only, **§6 конкретный пайплайн стенда vDC → Edge → IP → SNAT → Штурвал**, §7 регистр UUID, §8 чек-лист DevOps, §9 FAQ, §10 ссылки.
- Страница пайплайна переписана (`f8d6494`): требования, чек-лист услуги 150 (ALB + AVI ≥ 3, внешних IP ≥ 3), проверка результата (адреса API/Ingress), таблица «заморозки» при destroy, полное удаление, состав файлов.
- `provider-behavior.md` добавлена в навигацию `mkdocs.yml` («Руководства → Как работает провайдер (отличия от Terraform)») и связана ссылкой со страницей пайплайна (`8d25de9`).
**25.09 — стенд второго пользователя `FPipeGmail`**
- Организация `kontra` (аккаунт `tazetdinovn@gmail.com`, компания `naeel_test`, ClientID `WZ03709`), токен — `secrets/dev.token`; в `.tfvars` было закомментировано устаревшее `kontora` (`e20c22e`).
- Имена Штурвала сделаны свои: `shturval-dev1` / `shturval-dev-01` — занятое имя кластера (даже созданное другим пользователем) платформа не принимает (`e72eb75`).
- Проверено на 25.09: `terraform init` (провайдер 2.0.23), `validate` — ок, `plan` = `5 to add, 0 change, 0 destroy`. **apply не запускался** (запускает только пользователь).
**25.09 — репозиторий примеров `tf_examples`**
- `fullpipe_chain`: добавлен `shturval.tf` (переменные + locals + ресурс, самодостаточно), пин провайдера `2.0.21 → 2.0.23`, флаги «заморозки» как в рабочем стенде (эдж/SNAT/квота `keep_on_destroy = true`, vDC `suspend` + adopt), выводы Штурвала, блок параметров в `terraform.tfvars.example`, переписаны README примера и корневой (`21618a7`); добавлено предупреждение про уникальность имени кластера (`435a685`).
- Всё запушено: последний коммит примеров — `435a685`.
**26.09 (не в этой сессии, но уже в `master` — чтобы не переспрашивать)**
- Диаграмма зависимостей сервисов: `docs/diagrams/*` (`ea725bd`, `ff38352`, `6791e8f`), генератор `render_infra_diagram.py`, README с инструкцией перегенерации.
- Сверка цепочек зависимостей с YAML + чек-листы Штурвала и ВМ: `docs/60_strategy/dev_stand_service_dependencies.md` (`dc837e8`), перенос артефактов диаграмм + гайд по созданию vApp (`a366f47`).
- Закоммичены бэкапы от 25.09: `TMP/backup_2026-09-25/**` (`4800958`).
---
## 2. Текущее состояние (проверено 2026-09-27)
**Terraform**
- `DEV_STAND/FullPipe`: `terraform state list` — **пусто**.
- `DEV_STAND/FPipeGmail`: `terraform state list` — **пусто**; при этом конфиг валиден, `plan` = `5 to add` (по состоянию на 25.09).
- Terraform `1.9.5`; `go build ./...` и `go test ./...` — чисто (проверено 25.09).
**Облако, аккаунт `tazet@narod.ru` (токен `secrets/narodDEV.token`)**
| Услуга | Имя | UID | Статус |
|---|---|---|---|
| 150 Штурвал | `shturval-dev` | `94627ff4-33a5-48f2-aca1-695741e0b6a2` | `suspended` |
| 21 vDC | `fullpipe-vdc` | `d0937335-276b-475b-baa4-d8e6d16bad51` | `suspended` |
| 22 Edge | `fullpipe-edge` | `2c37fed1-e8f8-4a84-8434-7851c7c8b5d6` | `running`, SNAT `internet-ipv4-v1` |
| 19 Организация | `organ` | `57eeacd1-dc7f-4a52-b903-7e5f7d3c1164` | `running`, `vIPConfigure` = `[{internet-ipv4-v1, count: 3}]` |
Всего в аккаунте 13 инстансов, в том числе: `dummy-5`/`dummy-6` (1), `External ip` и `vIp` (25, `running`), `A-record` (111, `running`), инстансы mgmt-кластера (21/22, `deleted`). Из 3 инстансов `fullpipe-edge` — 2 `deleted` (история), 1 `running` — не пугаться.
**Облако, аккаунт `tazetdinovn@gmail.com` (токен `secrets/dev.token`, срок до 2026-12-27)**
- Организация `kontra` (`df5ec5f2-5f5d-4c82-952c-3dffa91c61d3`, услуга 19): `running`, `isTrial`, создана 2026-09-24 08:20, `vIPConfigure` = `count: 3`, realm `sandbox.nubes.ru`.
- В этом аккаунте **уже созданы объекты стенда FPipeGmail**: `fullpipe-vdc` (21) — `suspended`, `fullpipe-edge` (22) — `running`, `shturval-dev1` (150) — `suspended` (локальный `state` при этом пуст → следующий `apply` усыновит их по имени и разморозит). Всего 12 инстансов, плюс `kontra` (19), S3-бакеты (13), `s3naeeldev` (12), `mariamdb` (115), `vc_v`/`vc_vdc-1`/`tedj` — `deleted`.
- ⛔ **Исправление ранее ложного наблюдения** (в первой версии этого резюме было «список отдаёт 0 инстансов»): это была **ошибка парсинга ответа с моей стороны** — API отдаёт список в ключе `results`, а не `instances`. Оба аккаунта отдают данные корректно (narod — 13, gmail — 12). Никакой аномалии платформы нет.
**Провайдер / реестр / доки**
- DEV в реестре: **`2.0.23`** (последняя), пины `2.0.23` в `DEV_STAND/FullPipe/versions.tf`, `DEV_STAND/FPipeGmail/versions.tf`, `tf_examples/fullpipe_chain/versions.tf`, `.terraform.lock.hcl` FullPipe.
- PROD `1.0.0`, TEST `3.0.0` (новая схема: prod `1.*`, dev `2.*`, test `3.*`).
- Доки dev опубликованы (`2.0.23`) и зеркало на ВМ обновлено; живые URL:
- https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/curated/pipeline/vdc_edge_ip_snat/ (в меню: «Проверенные примеры»)
- https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/30_registry/guides/provider-behavior/ (в меню: «Руководства»)
**Git**
- Основной репо: `master` = `origin/master` = `e72eb75`; примеры: `435a685` = `origin/master`.
- В истории уже лежат коммиты от 26.09 (диаграммы, чек-листы, бэкапы) — см. §1.
---
## 3. Что делать дальше: **добавляем ВМ в пайплайн** (главная цель нового чата)
**Целевая цепочка (текущая + ВМ):**
организация (руками в ЛК) → vDC (21) → Edge (22) → внешние IP (modify) → SNAT (modify) → **[vApp (26) → ВМ (28)]** → Штурвал (150)
**Подтверждённые факты (сверено с YAML, коммит `dc837e8`)**
- D-06: vApp (26) → Edge (22), параметр `nsxtUid` — vApp использует Edge для сети.
- D-07: vApp (26) → vDC (21), параметр `vdcUid`.
- D-08: ВМ (28) → vApp (26), параметр `vappUid` — ВМ размещается в vApp.
- ВМ можно создавать в **уже существующих** vApp / vDC / Edge — ссылками `vappUid` / `vdcUid` / `nsxtUid`.
- Ресурсы (CPU/RAM/Storage) ВМ: `vmCpu`, `vmRam`, `vmDisk` (доп. диск); квоты выделяются на уровне vDC (21) и услуги ВМ (28).
- Чек-лист создания ВМ (28) по create-параметрам, обязательные: `vappUid`, `vmName`, `vmCpu`, `vmRam`, `accessPortList`, `imageVm`, `userLogin`, `userPublicKey`, `ipSpaceName`. Полный текст — `docs/60_strategy/dev_stand_service_dependencies.md` (§«Чек-лист создания ВМ (28)»).
- Спеки ресурсов: `generated/dev/resources_yaml/26_vapp.yaml`, `28_vc_vm_v3.yaml` (**руками не править** — перезапишет `01_generate_yamls.sh`).
- Имена ресурсов в провайдере: `nubes_vc_vapp`, `nubes_vc_vm_v3` (услуга 148 «Менеджмент Kubernetes Штурвал» — **не** то, что нужно; для кластера только 150).
**Известные проблемы ВМ (читать до работ)**
- `docs/20_discovery/vm_provisioning_failures.md` — падения на создании, разбор payload Terraform vs ручного запроса.
- `docs/40_analysis/vm_creation_failure_analysis.md`, `docs/50_history/04_vm_hang_fix_and_500_error.md` — зависание на FW и 500 («String[] → GUID»).
- `docs/50_history/12_vm_resource_hardening.md` — правки по надёжности ресурса ВМ.
- `docs/50_history/23_vapp_uid_inconsistency_displayname_resolve_bug.md` — резолв по имени мог вернуть **удалённый** инстанс (лечится фильтром `isDeleted=false`); проверить, что фикс жив в 2.0.23.
**Что уже выяснено фактами (проверено 27.09 — чтобы не гадать)**
- vApp (26), обязательные параметры create: `vappName`, `vdcUid`, `nsxtUid`. Операции: `create`, `delete`, `suspend`, `resume`; в YAML — `suspend_on_destroy_default: true`. Из инструкции услуги: `delete` требует **предварительного `suspend`**, полное удаление — через 14 дней. Отдельных параметров сети/подсети/gateway у vApp нет — сеть даёт Edge (`nsxtUid`).
- ВМ (28), обязательные параметры create: `vappUid`, `vmName`, `vmCpu`, `vmRam`, `ipSpaceName`, `imageVm`, `userLogin`, `userPublicKey`, `accessPortList` (`vmDisk` — дополнительный диск, не обязателен). Операции: `create`, `delete`, `modify`, `reconcile`, `redeploy`, `resume`, `suspend`; в YAML — `suspend_on_destroy_default: true`. В modify-наборе есть параметр `sameSnat` (разобраться, что даёт).
- `ipSpaceName` у ВМ — «Public IP setting», допускает значение `no-needed` (без публичного адреса). Если ВМ нужен внешний IP, квоту организации придётся увеличивать: сейчас `count = 3` и все три держит кластер Штурвала, а `suspend` адреса не освобождает.
- Соответствие кодов и ID параметров ВМ (`docs/20_discovery/vm_service.md`): `vappUid` 407, `vmName` 408, `vmCpu` 409/493, `vmRam` 410/494, `vmDisk` 411/495, `ipSpaceName` 412/496, `accessIpList` 413/497, `imageVm` 414, `cloudInit` 415, `userLogin` 416, `userPublicKey` 417, `accessPortList` 448/498, `needAddZabbixTemplate` 449/499.
- **Список образов (`imageVm`) в репозитории отсутствует**: в документации зафиксирован только пример `Ubuntu_22-20G`; HAR с созданием ВМ (`vmOK.har`, `vmsuspendresumemodify.har`) были в старом репо `/home/naeel/terra` — в этом репо в `HAR/` их нет (там `org*`, `vdc`, `edge_`, `ipSpace0`, `pgmodify`, `dummy`, `globak`). Значит: смотреть список в ЛК либо снять свежий HAR.
- Живых инстансов vApp (26) и ВМ (28) сейчас **нет ни в одном аккаунте** → `availableOperations` по ним снять не с чего, проверять после первого создания.
- Наличие `suspend` у обоих сервисов означает, что режим «заморозка» (`suspend_on_destroy` / `keep_on_destroy`) у них применим — как у кластера и vDC.
**Что предстоит решить (НЕ выбрано, требует решения пользователя)**
1. Где создавать vApp: в `DEV_STAND/FullPipe` (проверенный стенд, аккаунт `narod`) или отдельным файлом-аналогом `shturval.tf` (например `vm.tf`) — по сложившейся конвенции «всё про сервис в одном файле».
2. Очерёдность: ВМ до Штурвала или после (влияет и на внешние адреса, и на время apply).
3. `imageVm` — откуда брать список образов (в текущих заметках не зафиксировано) → проверять в ЛК/HAR.
4. `ipSpaceName` для ВМ — тот же `internet-ipv4-v1` или другой; хватит ли квоты (сейчас `count = 3`, занято кластером).
5. `userPublicKey` — какой ключ использовать (в репо есть `secrets/id_ed25519.pub`).
6. Режим destroy для vApp и ВМ (есть ли `suspend` у 26/28 — проверить `availableOperations` на живом инстансе перед выбором флагов).
7. Обновить после работ: `docs/curated/pipeline/vdc_edge_ip_snat.md` (или новую страницу), `tf_examples/fullpipe_chain` (файл ВМ + README + выводы), `docs/60_strategy/dev_stand_service_dependencies.md`, диаграмму `docs/diagrams/*` (перегенерировать `render_infra_diagram.py`).
**Правило порядка (из инструкций ЛК):** платформа строит список ipSpace из состояния `job.vcd.networkProvider` / `providerGateway`, то есть аллокация IP и SNAT идут **после** готовых vDC и Edge; иначе modify падает с «Can't cast Complex Object Type Struct to String».
---
## 4. Факты платформы (проверено, не перепроверять)
- 1 кластер Штурвала = 1 vDC; vDC удаляется только через **14 дней** после `suspend`.
- Квоту IP нельзя опустить ниже занятых адресов; `suspend` кластера адреса **не** освобождает.
- У Edge операции `suspend` нет вообще: доступны `delete`, `modify`, `reconcile`.
- Доступные операции: организация (19) — `delete`, `suspend`, `resume`, `modify`, `reconcile`, `create_user`, `delete_user`; vDC (21) — `delete`, `modify`, `suspend`, `resume`, `reconcile`; Штурвал (150) — то же + `create_user`, `delete_user`.
- API ЛК (dev): `GET /api/v1/svc/instances/{uid}`; поля: `explainedStatus`, `state.isSuspended`, `state.isDeleted`, `state.params`, `availableOperations`; ошибки операций приходят **в теле** (`isSuccessful=false`, `errorLog`) при HTTP 200/201.
- Обязательные заголовки: браузерный `User-Agent`, `Referer: https://deck-dev.ngcloud.ru/`, `Authorization: Bearer <токен>`; иначе 403.
- Регистр UUID: облако отдаёт один и тот же UUID в разных регистрах → сравнивать **без учёта регистра, в том числе внутри JSON** (учтено в 2.0.23).
- Имя кластера Штурвала должно быть **уникальным**: занятое (даже другим пользователем) не подойдёт; имя услуги и имя кластера — свои у каждого пользователя.
- `plan` не видит существующие объекты: adopt/конфликт проверяются в `Create` (в плане всегда «will be created»).
---
## 5. Шпаргалка: режимы 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` > обычное удаление. Дефолты генератора: `suspend_on_destroy = true` (для сервисов с операцией `suspend`), `keep_on_destroy = false`, `adopt_existing_on_create = false`. Обратный ход: `apply` усыновляет объект по имени и делает `resume`.
---
## 6. Релизы и публикация (команды)
```bash
# Релиз провайдера (dev)
TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev <версия>
# → сам прогоняет 01 (YAML) + 02 (Go/доки), собирает, подписывает, заливает в реестр
# → VERSIONS.md обновить вручную
# Локальная проверка без релиза
cd provider && go build -o ../TMP/devbin/terraform-provider-nubes .
TF_CLI_CONFIG_FILE=TMP/terraformrc.dev terraform validate|plan # dev_overrides
# Документация: собрать и залить в S3
TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/dev 2.0.23
# ⚠️ версию передавать АРГУМЕНТОМ: в TOOLS/config/dev/profile.env всё ещё VERSION="2.0.17"
# docker недоступен → сборка идёт локальным mkdocs 1.6.1 + mkdocs-material
# Довезти до домена (сейчас обязательно руками: с ВМ нет маршрута до S3)
tar -C site -cf - . | ssh vps 'rm -rf ~/tmp-docs-site && mkdir -p ~/tmp-docs-site && tar -C ~/tmp-docs-site -xf -'
ssh vps 'rsync -a --delete ~/tmp-docs-site/ /var/www/tf-docs/nubes-dev/'
# vps = naeel@5.172.178.213 (алиас в ~/.ssh/config, ключ ~/.ssh/naeel_vm_id_ed25519)
# путь из secrets/HOW_TO_SSH.md устарел (файла /home/naeel/remote_dev/... нет)
# Примеры
cd tf_examples && git add -A && git commit -m "..." && git push origin master
```
Проверка доков: `curl -s -o /dev/null -w "%{http_code}" <URL>` — должно быть 200; содержимое локальной сборки лежит в `site/` (каталог в `.gitignore`).
---
## 7. Правила работы (не нарушать)
- `terraform apply` и `destroy` — **только пользователь**. Мне доступны `init/plan/validate/show/state list/state show`.
- Никаких правок, коммитов, релизов и публикаций без прямой команды; команда непонятна — **спросить, не гадать**.
- После каждой правки — коммит с осмысленным сообщением; перед правкой важных файлов — бэкап в `TMP/backup_<дата>/`.
- Секреты (`terraform.tfvars`, токены) **не печатать** в терминал и не коммитить (`*.tfvars` в `.gitignore`); сравнивать по хешу/маскировать.
- Не расширять область задач (scope creep), не «улучшать» соседние стенды и репозитории.
- Не зацикливаться: два вызова инструмента с близкими аргументами без результата — стоп и доклад.
- Проверка примера — на копии в `/tmp`, не в `tf_examples/` (там нет gitignore для `.terraform/`, служебные файлы уехали бы в публичный репозиторий).
---
## 8. Быстрые команды
```bash
# состояние стендов
cd DEV_STAND/FullPipe && terraform state list && terraform plan
cd DEV_STAND/FPipeGmail && terraform state list && terraform plan
# кластер (когда running)
kubectl config current-context # tazet@narod.ru@shturval-dev-00
kubectl get nodes
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?page=1&size=200'
# реестр провайдера (dev)
curl -s https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/nubes-dev/nubes/versions
# услуги каталога: 19 орга, 21 vDC, 22 Edge/SNAT, 26 vApp, 28 ВМ, 150 Штурвал, 148 — не то
```
---
## 9. Открытые вопросы и техдолги
1. Ref-параметр **внутри JSON** не валидируется при adopt — `provider/internal/resources_core/ref_validation.go`.
2. Регистр ключей в `lookupLiveParam` — `provider/internal/core/operation_cfs.go:38` (в прежних заметках был указан неверный файл); нужна живая проверка.
3. Тикеты в платформу: TTL/очистка Failed-подов установщика Штурвала (порядок установки компонентов до готовности Cilium); у Edge нет операции `suspend`.
4. `TOOLS/config/dev/profile.env`: `VERSION="2.0.17"` — конфиг отстаёт от реестра (`2.0.23`); правка конфига без команды не делалась.
5. `DEV_STAND/FPipeGmail`: локальный `state` пуст, но объекты в облаке **уже есть** (`fullpipe-vdc` suspended, `fullpipe-edge` running, `shturval-dev1` suspended) — при применении Terraform их усыновит.
6. `DEV_STAND/FPipeGmail` — применять или нет (сейчас `plan` = 5 ресурсов, `state` пуст) — решение за пользователем.
@@ -51,8 +51,13 @@ k8sShturval -> create
## 3. Подтверждённые факты (с источниками)
1. **Схема tf-ресурса строится ТОЛЬКО из `create`** (генератор `TOOLS/resource-generator`).
→ modify-only параметры в схему не попадают.
1. ⛔ **ИСПРАВЛЕНО 2026-09-24. Прежняя формулировка «схема строится ТОЛЬКО из `create`» — НЕВЕРНА.**
Генератор **мержит** 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)
есть **только** в modify. В `create` (id 136) — только `resourceRealm`(418), `organizationType`(556), `orgSuffix`(1125).
Файл: `generated/dev/resources_yaml/19_vc_org.yaml`.
@@ -141,6 +146,11 @@ k8sShturval -> create
«Update = no-op». **Файл перезаписан** актуальными фактами. НЕ использовать старую формулировку.
3. **Старые «модификаторы» были написаны и даже работали** (09-22), но заход признан негодным:
доменную логику вшили в универсальный генератор (метки в 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/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/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` — промпт (факты исправлены)
- `generated/dev/resources_yaml/19_vc_org.yaml`, `22_vc_nsxt.yaml` — спеки (факты по операциям/параметрам)
- `HAR/org_enough_.har`, `HAR/org2.har`, `HAR/edge_.har` — live-семантика modify
+6 -3
View File
@@ -7,7 +7,10 @@
| Файл | Тема | Статус |
|---|---|---|
| `CHAT_RESUME_IAC_2026-09-24.md` | **Текущая линия: IaC + `modify` (Штурвал).** Задача, позиции участников (Георгий/Дмитрий/Виталий), 10 подтверждённых фактов, почему не работает простое, каноничное решение, развилка A–E, **исправленные ошибки**, карта файлов, открытые вопросы | ✅ **Актуально — начинать отсюда** |
| `CHAT_RESUME_2026-09-27_vm_in_pipeline.md` | **Актуальная линия: добавляем ВМ (услуги 26 vApp + 28 ВМ) в пайплайн.** Всё сделанное (freeze/adopt, 2.0.19→2.0.23, пример, доки), состояние стендов и облака на 27.09, точки входа по ВМ, известные проблемы, развилки | ✅ **Актуально — начинать отсюда** |
| `CHAT_RESUME_2026-09-25_shturval_freeze_state.md` | Состояние Штурвал/freeze на 25.09: диагностика стенда, режимы destroy, релиз 2.0.23, схема публикаций | ⚠️ История (всё вошло в резюме от 27.09) |
| `CHAT_RESUME_2026-09-24_shturval_freeze.md` | Adopt кластера + дизайн freeze-on-destroy | ⚠️ История |
| `CHAT_RESUME_IAC_2026-09-24.md` | Линия IaC + `modify` (Штурвал): факты, развилка A–E, карта файлов | ⚠️ История |
| `CHAT_RESUME_2026-09-22.md` | Предыдущая сессия (дата в имени) | ⚠️ История |
| `CHAT_RESUME_2026-09-21.md` | Предыдущая сессия | ⚠️ История |
| `CHAT_RESUME_2026-09-20.md` | Предыдущая сессия | ⚠️ История |
@@ -17,8 +20,8 @@
## Как пользоваться
1. Открыть `CHAT_RESUME_IAC_2026-09-24.md` — это сводка всей линии по IaC.
2. Внутри него есть ссылки на детальные документы (`../30_analysis/*`, `../20_prompts/*`).
1. Открыть `CHAT_RESUME_2026-09-27_vm_in_pipeline.md` — это сводка всей текущей линии (и всё, что уже сделано ранее).
2. Внутри него есть ссылки на детальные документы (`../30_analysis/*`, `../10_plans/*`, `../20_prompts/*`).
3. Устаревшие рестюме **не удалять** — они фиксируют состояние на свою дату (полезно для хронологии).
## Важно про даты
@@ -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]
}
+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
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,4 @@
provider "nubes" {
api_token = var.api_token
api_endpoint = var.api_endpoint
}
@@ -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\"}]"
@@ -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
}
@@ -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"
}
}
}
@@ -0,0 +1,85 @@
# vDC → Edge → внешние IP → SNAT
Пример поднимает сетевую основу в Nubes:
- виртуальный датацентр (vDC);
- сетевой шлюз периметра (Edge);
- внешние IP на организации;
- SNAT на шлюзе.
Кластер Штурвал сюда **не входит** — он разворачивается долго, отдельным шагом.
Организацию создайте заранее в ЛК: Terraform её не создаёт и не удаляет.
**Перед началом:** нужен установленный Terraform 1.5 или новее. Провайдер скачается сам
при `terraform init` — отдельно ставить ничего не нужно.
## 1. Скачайте пример
```bash
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
cd tf_examples/fullpipe_chain
```
## 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` |
Остальные значения уже подставлены в `terraform.tfvars.example` — меняйте, если нужно.
## 3. Заполните terraform.tfvars
```bash
cp terraform.tfvars.example terraform.tfvars
nano terraform.tfvars # подставьте значения из шага 2
```
Полный список параметров — в `variables.tf`, у каждой переменной есть описание.
## 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`) |
`terraform.tfvars` с токеном никому не передавайте и не коммитьте в git.
@@ -0,0 +1,23 @@
# Сетевой шлюз периметра (Edge) — создаётся внутри vDC, объявленного в vdc.tf.
resource "nubes_vc_nsxt" "edge" {
# имя услуги в ЛК (любое, удобное вам)
resource_name = var.nsxt_resource_name
# родительская услуга: vdc (нужен vdc_uid) или vdcGroup
vdc_type = var.nsxt_vdc_type
# ссылка на vDC: сначала создаётся vDC, потом шлюз
vdc_uid = nubes_vc_vdc.vdc.id
# балансировщик (ALB): включён; виртуальных сервисов 1..4,
# для кластера Штурвал нужно не меньше 3
need_enable_avi = var.nsxt_need_enable_avi
virtual_services_count = var.nsxt_virtual_services_count
# сеть шлюза: пул адресов (маска /24 обязательна) и DNS для машин
routed_net_configuration = {
ip_addr_pool = var.nsxt_ip_addr_pool
main_dns = var.nsxt_main_dns
second_dns = var.nsxt_second_dns
}
}
@@ -0,0 +1,61 @@
# =============================================================================
# Ресурсы, которые выполняют операции modify над уже созданными услугами.
#
# Порядок строго такой:
# организация (создаётся заранее в ЛК)
# -> nubes_vc_vdc.vdc
# -> nubes_vc_nsxt.edge
# -> nubes_vc_org_ip_allocation (внешние IP на организации)
# -> nubes_vc_nsxt_snat (SNAT на шлюзе этим ipSpace)
#
# Аллокация IP идёт ПОСЛЕ шлюза: платформа строит список доступных ipSpace
# только когда vDC и Edge уже созданы. Если поменять порядок — modify упадёт
# с ошибкой «Can't cast Complex Object Type Struct to String».
# =============================================================================
# 1. Внешние IP на организации.
resource "nubes_vc_org_ip_allocation" "org_ip" {
# та же организация, что у vDC: название услуги из ЛК или её UUID
organization = var.organization
# сколько IP выделить: name — имя ipSpace, count — количество (строкой).
# массив передаётся целиком, поэтому старые значения перезаписываются
vip_configure = jsonencode([
{
name = var.ip_space_name
count = var.ip_count
}
])
# при destroy: false — квота обнуляется (count=0); true — оставить как есть
keep_on_destroy = false
depends_on = [nubes_vc_nsxt.edge]
}
# 2. SNAT на шлюзе: внешние адреса для машин.
resource "nubes_vc_nsxt_snat" "snat" {
# UUID шлюза, созданного выше
nsxt_uid = nubes_vc_nsxt.edge.id
ip_space_name = var.ip_space_name
# при destroy: false — SNAT выключается (no-needed); true — оставить как есть
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
}
@@ -0,0 +1,32 @@
# Значения, нужные для дальнейшей работы (например, чтобы привязать к шлюзу
# кластер Штурвал или другие услуги).
output "vdc_id" {
description = "UUID созданного vDC"
value = nubes_vc_vdc.vdc.id
}
output "vdc_name" {
description = "Имя vDC"
value = nubes_vc_vdc.vdc.resource_name
}
output "nsxt_id" {
description = "UUID созданного Edge (сетевого шлюза периметра)"
value = nubes_vc_nsxt.edge.id
}
output "nsxt_name" {
description = "Имя Edge"
value = nubes_vc_nsxt.edge.resource_name
}
output "vdc_state_params" {
description = "Параметры vDC из API (что реально создалось)"
value = nubes_vc_vdc.vdc.state_params
}
output "nsxt_state_params" {
description = "Параметры Edge из API (что реально создалось)"
value = nubes_vc_nsxt.edge.state_params
}
@@ -0,0 +1,6 @@
# Подключение к API Nubes.
# Токен и адрес API берутся из terraform.tfvars (переменные api_token, api_endpoint).
provider "nubes" {
api_token = var.api_token
api_endpoint = var.api_endpoint
}
@@ -0,0 +1,53 @@
# =============================================================================
# Шаблон значений для fullpipe_chain.
#
# 1. Скопируйте файл: cp terraform.tfvars.example terraform.tfvars
# 2. Заполните значения ниже (откуда брать — в комментариях и в README.md).
# 3. terraform.tfvars НЕ публикуйте и не коммитьте: в нём токен доступа.
# =============================================================================
# ЛК → Профиль → Токены → «Технический»
api_token = "eyJhbGciOi..."
# Название услуги «Организация в Cloud Director» в ЛК (например: organ).
# Организацию создайте заранее в ЛК — Terraform её не создаёт и не удаляет.
organization = "organ"
# Имя ipSpace для внешнего IP. Смотреть в ЛК в карточке организации.
ip_space_name = "internet-ipv4-v1"
# Сколько внешних IP выделить (строкой).
ip_count = "3"
# --- vDC (виртуальный датацентр) ---
# Имя vDC в ЛК — любое.
vdc_resource_name = "fullpipe-vdc"
# ЛК → создание vDC → «Сетевой провайдер»
vdc_network_provider = "snb1"
# ЛК → создание vDC → «Provider VDC»
vdc_provider_vdc = "Intel Broadwell 2.4"
# vCPU, шт.
vdc_cpu_allocated = 8
# Резервирование vCPU, %: допустимы 0, 50 или 80
vdc_cpu_guaranteed = 0
# RAM, ГБ
vdc_mem_allocated = 32
# Дисковая политика: JSON-массив, name — имя политики из ЛК, size — размер в ГБ.
# Пример: политика SATA, 200 ГБ.
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
# --- Edge (сетевой шлюз периметра) ---
# Имя Edge в ЛК — любое.
nsxt_resource_name = "fullpipe-edge"
# Родительская услуга: vdc или vdcGroup.
nsxt_vdc_type = "vdc"
# Балансировщик AVI (ALB).
nsxt_need_enable_avi = true
# Виртуальных сервисов AVI: 1..4. Для кластера Штурвал нужно не меньше 3.
nsxt_virtual_services_count = 3
# Адресный пул routed-сети шлюза. Маска /24 обязательна.
nsxt_ip_addr_pool = "10.10.102.0/24"
# DNS для машин за шлюзом.
nsxt_main_dns = "81.22.46.22"
nsxt_second_dns = "185.247.187.77"
@@ -0,0 +1,123 @@
# =============================================================================
# Переменные. Все значения задаются в terraform.tfvars.
# Шаблон файла: terraform.tfvars.example (скопируйте в terraform.tfvars).
# Где взять каждое значение — в описании переменной и в README.md.
# =============================================================================
variable "api_token" {
type = string
sensitive = true
description = "Токен API. ЛК → Профиль → Токены → «Технический». Никому не передавайте: токен даёт полный доступ к услугам."
}
variable "api_endpoint" {
type = string
default = "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc"
description = "Адрес API. Менять не нужно."
}
# --- Организация ---
variable "organization" {
type = string
description = "Название услуги «Организация в Cloud Director» в ЛК (например: organ). Можно указать и её UUID. Организация создаётся заранее в ЛК — Terraform её не создаёт."
}
variable "ip_space_name" {
type = string
description = "Имя ipSpace для внешнего IP. Смотреть в ЛК в карточке организации (например: internet-ipv4-v1)."
}
variable "ip_count" {
type = string
default = "3"
description = "Сколько внешних IP выделить на организации. Строкой — так его отдаёт платформа."
}
# --- vDC (виртуальный датацентр) ---
variable "vdc_resource_name" {
type = string
default = "fullpipe-vdc"
description = "Имя vDC в ЛК. Любое, удобное вам."
}
variable "vdc_network_provider" {
type = string
default = null
description = "Сетевой провайдер vDC. Взять в ЛК на странице создания vDC (например: snb1)."
}
variable "vdc_provider_vdc" {
type = string
default = null
description = "Provider VDC. Взять в ЛК на странице создания vDC (например: Intel Broadwell 2.4)."
}
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, ГБ."
}
variable "vdc_storage_config" {
type = string
default = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
description = "Дисковая политика: JSON-массив, name — имя политики из ЛК, size — размер в ГБ (строкой). Имя политики должно существовать в ресурсном пуле (например: SATA, SSD)."
}
# --- Edge (сетевой шлюз периметра, vc_nsxt) ---
variable "nsxt_resource_name" {
type = string
default = "fullpipe-edge"
description = "Имя Edge в ЛК. Любое, удобное вам."
}
variable "nsxt_vdc_type" {
type = string
default = "vdc"
description = "Тип родительской услуги: vdc (нужен vdc_uid) или vdcGroup."
}
variable "nsxt_need_enable_avi" {
type = bool
default = true
description = "Включить балансировщик AVI (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 для машин за шлюзом."
}
@@ -0,0 +1,27 @@
# Виртуальный датацентр (vDC) — создаётся внутри организации из terraform.tfvars.
resource "nubes_vc_vdc" "vdc" {
# имя услуги в ЛК (любое, удобное вам)
resource_name = var.vdc_resource_name
# организация, к которой привязан vDC: название услуги из ЛК или её UUID
organization_uid = var.organization
# значения из ЛК со страницы создания vDC
network_provider = var.vdc_network_provider
provider_vdc = var.vdc_provider_vdc
# квоты: vCPU (шт.), резервирование vCPU (%), RAM (ГБ)
# для cpu_guaranteed допустимы только 0, 50 или 80
cpu_allocated = var.vdc_cpu_allocated
cpu_guaranteed = var.vdc_cpu_guaranteed
mem_allocated = var.vdc_mem_allocated
# дисковые политики: JSON-массив, name — имя политики из ЛК, size — размер в ГБ
storage_config = var.vdc_storage_config
# при destroy не удалять vDC, а приостановить (данные сохраняются)
suspend_on_destroy = true
# если услуга с таким именем уже есть — подключиться к ней, а не падать с ошибкой
adopt_existing_on_create = true
}
@@ -0,0 +1,12 @@
# Версия Terraform и провайдера Nubes.
# Провайдер скачается сам при `terraform init` — вручную ставить ничего не нужно.
terraform {
required_version = ">= 1.5.0"
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "2.0.21"
}
}
}
@@ -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).
@@ -0,0 +1,32 @@
# Примеры Terraform для Nubes
> ## Внимание: рабочая папка одна
>
> Для работы используйте **только** [`fullpipe_chain`](fullpipe_chain/) — это пример
> «vDC → Edge → внешние IP → SNAT». Он прогнан на живом стенде (создание и удаление)
> и снабжён пошаговой инструкцией.
>
> Остальные папки репозитория — черновики: могут быть сырыми, содержать неактуальные
> версии провайдера и работать неправильно. **Не используйте их.**
Пошаговая инструкция: <https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/curated/pipeline/vdc_edge_ip_snat/>
## fullpipe_chain — что использовать
```bash
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
cd tf_examples/fullpipe_chain
cp terraform.tfvars.example terraform.tfvars # заполнить значениями из ЛК
terraform init
terraform plan # покажет 4 ресурса, ничего не меняет
terraform apply # подтвердить: yes
```
Подробности и список значений — в [README примера](fullpipe_chain/README.md).
Организацию создайте заранее в ЛК: Terraform её не создаёт и не удаляет.
## Остальные папки — не использовать
`CRUD`, `modify_resources`, `SHTURVAL_MGMT`, `iot-rmq-demo` — черновики от прошлых работ.
Могут быть сырыми и не поддерживаются. Берите только `fullpipe_chain`.
+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
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,4 @@
provider "nubes" {
api_token = var.api_token
api_endpoint = var.api_endpoint
}
@@ -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-dev1"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-01"
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,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
}
@@ -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"
}
}
}
@@ -0,0 +1,18 @@
НИКАКОЙ САМОДЕЙТЕЛЬНОСТИ !!! делать ТОЛЬКО ТО НА ЧТО ПОЛУЧЕНО РАЗРЕШЕНИЕ !!!!
НИКАКИХ ДОГАДОК !!! ЕСТь сомнения - СПРОСИ !!!
НИКОГДА НЕ ДЕЛАЙ ПРЕДПОЛОЖЕНИЙ !!!
ВСЕГДА СПРАШИВАЙ, ЕСЛИ НЕ УВЕРЕН !!!
НИКОГДА НЕ ИГНОРИРУЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
ВСЕГДА ПОДТВЕРЖДАЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
НИКОГДА НЕ ИЗМЕНЯЙ ИНСТРУКЦИИ БЕЗ РАЗРЕШЕНИЯ !!!
ВСЕГДА СОБЛЮДАЙ ПОРЯДОК И ПОСЛЕДОВАТЕЛЬНОСТЬ В ИНСТРУКЦИЯХ !!!
НИКОГДА НЕ ПРЕВЫШАЙ СВОИ ПОЛНОМОЧИЯ !!!
ВСЕГДА СОБЛЮДАЙ БЕЗОПАСНОСТЬ И КОНФИДЕНЦИАЛЬНОСТЬ !!!
НИКОГДА НЕ ПЕРЕДАВАЙ СЕКРЕТЫ ИНТЕРНЕТУ БЕЗ РАЗРЕШЕНИЯ !!!
НЕ ВЫЗЫВАТЬ ДРУГИЕ АГЕНТЫ БЕЗ РАЗРЕШЕНИЯ !!!
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
ЕСЛИ В ЧЁМ совменваешься - ОСТАНОВИСЬ И СПРОСИ !!!! НИКОГДА не действуй по догадкам и преположенгиям !!! ПЕРЕПРОВЕРЯЙ ВСЁ НЕСКОЛЬКО РАЗ !!!
+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
}
keepOnDestroy := false
if spec.Lifecycle.KeepOnDestroyDefault != nil {
keepOnDestroy = *spec.Lifecycle.KeepOnDestroyDefault
}
gr := types.GenResource{
Name: spec.Name,
ServiceID: spec.ServiceID,
@@ -128,6 +133,7 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
HasRefSvcParams: HasRefSvcParams(schemaParams),
SupportsSuspendDestroy: supportsSuspendDestroy,
SuspendOnDestroy: suspendOnDestroy,
KeepOnDestroy: keepOnDestroy,
AdoptExistingOnCreate: adoptExistingOnCreate,
HasDomainParam: hasDomainParam,
}
@@ -79,6 +79,7 @@ type {{ToCamel .Name}}Model struct {
{{- if .SupportsSuspendDestroy }}
SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + `
{{- end }}
KeepOnDestroy types.Bool ` + "`" + `tfsdk:"keep_on_destroy"` + "`" + `
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
{{- range .OutputParams }}
{{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
@@ -128,6 +129,7 @@ func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.Sch
{{- if .SupportsSuspendDestroy }}
"suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})},
{{- 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}})},
{{- range .OutputParams }}
{{- if or (OutputIsMap .) (OutputIsList .) }}
@@ -543,14 +545,15 @@ func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.Del
return
}
deleteMode := "delete"
{{- if .SupportsSuspendDestroy }}
deleteMode := "state_only"
if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() {
deleteMode = "suspend"
}
{{- else }}
deleteMode := "delete"
{{- end }}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
deleteMode = "state_only"
}
operationTimeout := ""
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())
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) {
@@ -32,6 +32,9 @@ type ServiceSpec struct {
Lifecycle struct {
SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"`
AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"`
// KeepOnDestroyDefault: дефолт ресурса для режима state_only (ничего не менять в облаке).
// Нужен там, где сервис не умеет suspend (например, Эдж) — иначе destroy удаляет объект.
KeepOnDestroyDefault *bool `yaml:"keep_on_destroy_default"`
} `yaml:"lifecycle"`
Operations []OperationSpec `yaml:"operations"`
}
@@ -75,18 +78,21 @@ type GenResource struct {
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
// KeepOnDestroy: destroy только убирает ресурс из состояния (state_only),
// ничего не меняя в облаке. Приоритетнее suspend_on_destroy.
KeepOnDestroy 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.
@@ -154,12 +160,12 @@ type GenAction struct {
// GenModifier — отдельный ресурс для отложенной parent-level modify операции.
// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён.
type GenModifier struct {
ServiceName string
ServiceID int
ModifierName string
OperationName string
Params []Param
SchemaParams []Param
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).
@@ -164,6 +164,13 @@ if [[ -n "${MKDOCS_DOCS_DIR:-}" ]]; then
mkdir -p "${MKDOCS_DOCS_DIR}/curated"
cp -r "${ROOT_DIR}/docs/curated/"* "${MKDOCS_DOCS_DIR}/curated/" 2>/dev/null || true
fi
# Картинки схемы зависимостей (docs/diagrams/*.svg|png) — чтобы их можно было
# показывать на опубликованных страницах. Исходники (.mmd, .py) не копируются.
if [[ -d "${ROOT_DIR}/docs/diagrams" ]]; then
mkdir -p "${MKDOCS_DOCS_DIR}/diagrams"
cp "${ROOT_DIR}/docs/diagrams/"*.svg "${MKDOCS_DOCS_DIR}/diagrams/" 2>/dev/null || true
cp "${ROOT_DIR}/docs/diagrams/"*.png "${MKDOCS_DOCS_DIR}/diagrams/" 2>/dev/null || true
fi
fi
# Per-стенд подстановка во все скопированные Markdown-файлы.
+1 -1
View File
@@ -5,7 +5,7 @@
| Стенд | Namespace | Версия | Дата заливки |
|---|---|---|---|
| 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 | (новая нумерация) |
## Как проверить
@@ -0,0 +1,222 @@
# Как работает провайдер 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 |
| Виртуальный каталог ВМ (26) | `nubes_vapp` | создаёт vApp (каталог для ВМ) на существующих vDC и Edge |
| Виртуальная машина (28) | `nubes_vc_vm_v3` | создаёт ВМ внутри vApp (CPU/RAM/диск, образ, SSH-ключ, внешний IP) |
| 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, vApp, ВМ (по умолчанию `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` |
| vApp | `suspend` | adopt по имени + `resume` |
| ВМ | `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 → vApp → ВМ → Штурвал
Порядок создания и зависимости:
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_vapp` — vApp: привязка к vDC (`vdc_uid`) и Edge (`nsxt_uid`); создаётся после SNAT,
чтобы сеть была уже готова.
7. `nubes_vc_vm_v3` — ВМ внутри vApp (`vapp_uid`): образ, CPU/RAM/диск, учётка и SSH-ключ, порты FW.
8. `nubes_k8s_sthutrval_cluster` — кластер Штурвала (по `depends_on` после SNAT: нодам нужен выход в интернет).
При удалении Terraform идёт в обратном порядке. Ограничения платформы, которые встречаются на этом пути:
- **1 кластер Штурвала = 1 vDC** (действующее ограничение услуги).
- vDC удаляется только **через 14 дней после `suspend`**; при живых Edge/vApp/VM/кластере — через поддержку.
- У vApp удаление требует **предварительного `suspend`**, полное удаление — тоже через 14 дней.
- У vApp и ВМ есть `suspend` и `resume`; у Edge операции `suspend` нет вообще.
- **ВМ не включится, если в vDC не осталось vCPU**: Штурвал при настройках по умолчанию занимает всё
(2 ноды × 4 vCPU при `vdc_cpu_allocated = 8`). Платформа отвечает общей ошибкой
`[400:VALIDATION] Unable to perform this action` на этапе power-on, инстанс остаётся в `not created`.
- Создание ВМ идёт дольше остальных ресурсов (customization + power-on) — задавайте `operation_timeout = "15m"`.
- 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. Куда смотреть дальше
- [Как развернуть цепочку: vDC → Edge → внешние IP → SNAT → vApp → ВМ → Штурвал](https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/curated/pipeline/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`.
@@ -0,0 +1,116 @@
# Зависимости облачных сервисов каталога DEV
Промежуточная инвентаризация для будущего общего графа. Источник — YAML-контракты
в `generated/dev/resources_yaml`.
Вершина — сервис каталога. Направление будущей стрелки: `потребитель -> зависимость`.
В таблицу зависимостей включены только связи, явно подтверждённые полем
`ref_svc_id` или тем же контрактом сервиса.
## Все сервисы каталога
| ID | Сервис | YAML |
|---:|---|---|
| 1 | Болванка | `1_dummy.yaml` |
| 2 | Темплейт k8s | `2_template.yaml` |
| 12 | S3 Object Storage | `12_s3.yaml` |
| 13 | S3 бакет | `13_s3bucket.yaml` |
| 19 | Организация в Cloud Director | `19_vc_org.yaml` |
| 21 | Виртуальный датацентр (vDC) | `21_vc_vdc.yaml` |
| 22 | Сетевой шлюз периметра (Edge) | `22_vc_nsxt.yaml` |
| 25 | Публичные IP адреса | `25_vcexternalip.yaml` |
| 26 | Виртуальный каталог ВМ (vApp) | `26_vapp.yaml` |
| 28 | Виртуальная машина | `28_vc_vm_v3.yaml` |
| 29 | Группа датацентров | `29_vc_vdc_group.yaml` |
| 50 | Nextcloud | `50_nextcloud.yaml` |
| 81 | Apache Superset | `81_superset.yaml` |
| 82 | Container Registry | `82_harbor.yaml` |
| 86 | Velero | `86_k8s_velero.yaml` |
| 87 | Valkey | `87_k8svalkey.yaml` |
| 88 | k8sZitiController | `88_k8s_ziti_controller.yaml` |
| 89 | Web-сервер с фреймворком Flask | `89_flask.yaml` |
| 90 | PostgreSQL | `90_postgres.yaml` |
| 91 | Redis | `91_redis.yaml` |
| 92 | MongoDB | `92_mongodb.yaml` |
| 93 | RabbitMQ | `93_rabbitmq.yaml` |
| 94 | Lucee | `94_lucee.yaml` |
| 95 | NodeJS | `95_nodejs.yaml` |
| 96 | pgAdmin | `96_pgadmin.yaml` |
| 97 | NodeRed | `97_nodered.yaml` |
| 98 | Простой HTTP контейнер | `98_http.yaml` |
| 99 | Gitea | `99_gitea.yaml` |
| 109 | Управление DNS | `109_zones_v2.yaml` |
| 111 | DNS запись | `111_dnsrecord.yaml` |
| 115 | Mariadb | `115_mariadb.yaml` |
| 116 | ApacheKafka | `116_kafka.yaml` |
| 119 | Akhq | `119_akhq.yaml` |
| 120 | ClickHouse | `120_clickhouse.yaml` |
| 148 | Менеджмент Kubernetes кластер Штурвал | `148_vc_mgmt_sthutrval_cluster.yaml` |
| 149 | VALO Cloud | `149_valo_tenant.yaml` |
| 150 | Kubernetes кластер Штурвал | `150_k8s_sthutrval_cluster.yaml` |
| 151 | Vault | `151_k8s_openbao.yaml` |
| 153 | Nifi | `153_nifi.yaml` |
| 163 | ML Улей | `163_llm_ai.yaml` |
## Подтверждённые зависимости
| ID | Потребитель | Зависимость | Поле связи | Смысл связи |
|---|---|---|---|---|
| D-01 | DNS запись (111) | Управление DNS (109) | `zoneUid` | запись создаётся в DNS-зоне |
| D-02 | S3 бакет (13) | S3 Object Storage (12) | `s3UserUid` | бакет создаётся для корневой S3-услуги |
| D-03 | Виртуальный датацентр (21) | Организация в Cloud Director (19) | `organizationUid` | vDC принадлежит организации |
| D-04 | Сетевой шлюз периметра (22) | Виртуальный датацентр (21) | `vdcUid` | Edge привязывается к vDC |
| D-05 | Сетевой шлюз периметра (22) | Группа датацентров (29) | `vdcGroupUid` | альтернативная привязка Edge к группе vDC (vdcType=vdcGroup) |
| D-06 | Виртуальный каталог ВМ (26) | Сетевой шлюз периметра (22) | `nsxtUid` | vApp использует Edge для сети |
| D-07 | Виртуальный каталог ВМ (26) | Виртуальный датацентр (21) | `vdcUid` | vApp размещается в vDC |
| D-08 | Виртуальная машина (28) | Виртуальный каталог ВМ (26) | `vappUid` | VM размещается в vApp |
| D-09 | Группа датацентров (29) | Виртуальный датацентр (21) | `vdcUid` | группа объединяет vDC (create и add_vdc) |
| D-10 | Akhq (119) | ApacheKafka (116) | `kafkaUid` | UI подключается к Kafka-кластеру |
| D-11 | Container Registry (82) | S3 Object Storage (12) | `s3Uid` | registry использует S3 для хранения |
| D-12 | Gitea (99) | PostgreSQL (90) | `psqlUid` | Gitea использует PostgreSQL |
## Переиспользование существующих инстансов
Зависимости (кроме Организации) являются ссылками на **существующие инстансы** через UUID:
- Новую ВМ (28) можно создать в уже существующем vApp, vDC, Edge — ссылкой `vappUid`/`vdcUid`/`nsxtUid`.
- Edge (22) можно переиспользовать, если он привязан к нужному vDC или groupvDC.
- Для Штурвала (150) переиспользуемый Edge должен иметь включённый ALB и `virtualServicesCount >= 3`.
- Внешние IP резервируются только после: Организация + vDC + Каталог (vApp) + Edge (по `25_vcexternalip.yaml`, `service_man`).
## Где выделяются ресурсы (CPU / RAM / Storage / IP)
| Элемент | Где задаётся | Параметр(ы) |
|---|---|---|
| Пул CPU/RAM/Storage организации | vDC (21) create/modify | `cpuAllocated`, `memAllocated`, `storageConfig` (+ разовый `cpuGuaranteed` 0/50/80) |
| Запрос ресурсов нод Штурвала | Кластер Штурвал (150) | `controlPlaneConfiguration.sizingPolicy/sizingDisk/count`, `workerConfiguration…` (политики из vDC) |
| Запрос ресурсов ВМ | Виртуальная машина (28) | `vmCpu`, `vmRam`, `vmDisk` (доп. диск) |
| Внешние IP | vcOrg (19) `modify` (`vIPConfigure`) или `vcexternalip` (25) | `ipSpaceName`, квота адресов |
| SNAT-правило | Edge (22) `modify` | `ipSpaceName` |
| DNAT/SNAT-правила адреса | Публичные IP (25) | `dnatCreate`/`snatCreate`, `internalAddrAccess`, `internalPortAccess` |
## Чек-лист создания Штурвала (150) — по `service_man`
1. Организация (19) — create.
2. vDC (21) — create, выделить ресурсы под все ноды.
3. Edge (22) — create, включить ALB и `virtualServicesCount >= 3`.
4. Внешние адреса в организации — vcOrg `modify`, суммарно 3 адреса.
5. SNAT — Edge `modify`, выбрать `ipSpaceName`.
6. Кластер Штурвал (150) — create (требует `vdcUid` и `nsxtUid`).
## Чек-лист создания ВМ (28) — по create-параметрам
1. vDC (21) — существующий (`vdcUid`).
2. Edge (22) — существующий (`nsxtUid`).
3. vApp (26) — существующий (`vappUid`), привязан к vDC и Edge.
4. ВМ (28) — create; обязательные: `vappUid`, `vmName`, `vmCpu`, `vmRam`, `accessPortList`, `imageVm`, `userLogin`, `userPublicKey`, `ipSpaceName`.
## Правила для будущего графа
1. Рисовать стрелку только по подтверждённой связи сервиса каталога.
2. `ref_svc_id` считать прямой зависимостью; `descr` использовать для подписи
стрелки и проверки смысла.
3. Не считать общей платформой, DNS-полем или внешним IP автоматическую связь
между сервисами без ссылки на конкретный сервисный ID.
4. Поля без `ref_svc_id` вынести в отдельный список кандидатов на проверку, но не
смешивать с подтверждёнными рёбрами.
@@ -239,5 +239,59 @@ gr.NeedsStringsImport = hasRestoreCasingParams(gr.SchemaParams) || analyzeNeedsS
## 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 ... │
└──────────────────────────────────────┘
```
+77
View File
@@ -0,0 +1,77 @@
# Ресурсы-модификаторы: внешние IP организации и SNAT на шлюзе
Два ресурса работают с **уже существующими** услугами:
- `nubes_vc_org_ip_allocation` — выделяет внешние IP на организации;
- `nubes_vc_nsxt_snat` — включает SNAT на сетевом шлюзе периметра.
Эти параметры нельзя задать при создании услуги обычным ресурсом, поэтому они вынесены в отдельные
ресурсы: так вся цепочка поднимается одним `apply` и в правильном порядке.
Оба ресурса проверены живым прогоном: создание, проверка состояния в ЛК и удаление.
Полный рабочий пример (vDC + Edge + IP + SNAT + vApp + ВМ + Штурвал) — на странице
[«Как развернуть цепочку: vDC → Edge → внешние IP → SNAT → vApp → ВМ → Штурвал»](../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` — строкой. Для Штурвала нужно не меньше 3 адресов, ещё один — если у ВМ будет внешний адрес |
| `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"`;
- **считайте потребителей адресов**: кластер Штурвал держит 3 адреса (API и Ingress), а ВМ с внешним
адресом (`ip_space_name = "internet-ipv4-v1"`) занимает ещё один — итого `count = "4"`.
Адреса освобождаются только после удаления потребителя: `suspend` их не снимает.
## `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 из карточки услуги в ЛК
```
+236
View File
@@ -0,0 +1,236 @@
# Как развернуть цепочку: vDC → Edge → внешние IP → SNAT → vApp → ВМ → Штурвал
Пошаговая инструкция. Готовые файлы примера — в репозитории `tf_examples`, папка `fullpipe_chain`.
Что получится в итоге:
- виртуальный датацентр (vDC);
- сетевой шлюз периметра (Edge) с балансировщиком AVI;
- внешние IP на организации;
- SNAT на шлюзе;
- виртуальный каталог (vApp) и виртуальная машина (ВМ) внутри него — с доступом по SSH;
- Kubernetes-кластер Штурвал (сервис 150) на этой сети.
![Схема зависимостей облачных сервисов](../../diagrams/infra_services_diagram.svg)
**Организацию создайте заранее в ЛК** — Terraform её не создаёт и не удаляет.
Кластер Штурвал создаётся десятки минут, поэтому провайдер ждёт его до часу
(`operation_timeout = "60m"`); ВМ тоже создаётся небыстро (у неё отдельный таймаут `15m`),
остальные ресурсы — минуты.
## Что нужно перед началом
| Требование | Зачем | Где смотреть |
|---|---|---|
| Terraform 1.5 или новее | работает провайдер | `terraform version` |
| Токен API | доступ к ЛК | ЛК → Профиль → Токены → «Технический» |
| Организация в Cloud Director | всё создаётся внутри неё | услуга «Организация в Cloud Director» |
| Edge с балансировщиком AVI | без ALB кластер Штурвал не поднимется | `nsxt_need_enable_avi = true`, `nsxt_virtual_services_count >= 3` |
| Внешние IP: не меньше 3 + 1 под ВМ | адрес Kubernetes API, адрес Ingress, адрес ВМ и запас | `ip_count = "4"` |
| SNAT на Edge | выход в интернет для машин кластера и для ВМ | ресурс `nubes_vc_nsxt_snat` |
| Публичный SSH-ключ | вход на ВМ по SSH | `~/.ssh/id_ed25519.pub` → `vm_user_public_key` |
| Запас vCPU в vDC | Штурвал занимает 8 vCPU из 8 при настройках по умолчанию, и тогда ВМ не включится | `vdc_cpu_allocated` |
Порядок из чек-листа услуги 150 (именно так связаны ресурсы в примере):
организация → vDC → Edge (ALB, AVI ≥ 3) → внешние IP (≥ 3) → SNAT → **vApp → ВМ** → кластер Штурвал.
Минимум для кластера: 1 мастер-нода и 1 воркер-нода по 4 vCPU / 8 ГБ RAM / 50 ГБ диска;
минимум для ВМ: 1 vCPU / 1 ГБ RAM (в примере — 2 vCPU / 2 ГБ).
## 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 = "4" # 3 под Штурвал + 1 под ВМ
# vDC
vdc_resource_name = "fullpipe-vdc" # имя услуги в ЛК, любое
vdc_network_provider = "snb1" # ЛК → создание vDC → «Сетевой провайдер»
vdc_provider_vdc = "Intel Broadwell 2.4" # ЛК → создание vDC → «Provider VDC»
vdc_cpu_allocated = 12 # 8 под Штурвал + 2 под ВМ + 2 запаса
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
# vApp и ВМ
vapp_name = "fullpipe-vapp-01" # имя vApp: маска ^[a-z0-9][a-z0-9.-]{3,61}[a-z0-9]$, уникально в организации
vm_name = "web01" # имя ВМ: задаёт имя IP Set {vapp_name}-{vm_name}
vm_image = "Ubuntu_22-20G" # RockyLinux_9-16G-cloudinit | Ubuntu_22-20G | Debian_13-20G
vm_cpu = 2 # vCPU, 1..64
vm_ram = 2 # RAM, ГБ, 1..256
vm_disk = 20 # дополнительный диск, ГБ
vm_user_login = "ubuntu" # учётка SSH
vm_user_public_key = "ssh-ed25519 AAAA..." # содержимое ~/.ssh/id_ed25519.pub
vm_same_snat = false # false — за общим SNAT шлюза
```
Описание всех параметров — в `variables.tf`. `terraform.tfvars` с токеном никому не передавайте
и не коммитьте в git.
## 4. Выполните команды
```bash
terraform init # один раз — скачает провайдер
terraform plan # покажет, что будет создано: 7 ресурсов (vDC, Edge, IP, SNAT, vApp, ВМ, Штурвал)
terraform apply # создаст (подтвердить: yes)
```
## 5. Проверьте результат
```bash
terraform output # UUID и имена созданных услуг
```
И в ЛК: появились vDC, Edge, vApp с ВМ и кластер Штурвал, на организации выделены внешние IP,
на шлюзе включён SNAT. Повторный `terraform plan` должен показать `No changes`.
Адрес ВМ виден в `terraform output vm_state_flat` — поля `externalConnect` (внешний IP),
`internalConnect` (адрес в routed-сети) и `fqdn`: `ssh <vm_user_login>@<externalConnect>`.
Кластер создаётся десятки минут — провайдер ждёт его (`operation_timeout = "60m"`),
в ЛК он появится со статусом `running`. Адреса Kubernetes API и Ingress — в
`terraform output shturval_state_params` (поля `kubernetesApiAddress` и `ingressAddress`).
## 6. vApp и ВМ
Виртуальная машина живёт **внутри vApp**: vApp привязывается к vDC (`vdc_uid`) и к Edge (`nsxt_uid`),
а ВМ — к vApp (`vapp_uid`) и получает адрес в routed-сети шлюза. Своей сети у vApp нет.
| Ресурс | Услуга | Ключевые параметры |
|---|---|---|
| `nubes_vapp` | 26 | `vapp_name` (уникальное в организации, маска DNS-имени), `vdc_uid`, `nsxt_uid` |
| `nubes_vc_vm_v3` | 28 | `vapp_uid`, `vm_name`, `vm_cpu`, `vm_ram`, `vm_disk`, `image_vm`, `user_login`, `user_public_key`, `ip_space_name`, `access_port_list` |
### Параметры ВМ
| Параметр | Значения |
|---|---|
| `image_vm` | `RockyLinux_9-16G-cloudinit`, `Ubuntu_22-20G`, `Debian_13-20G` |
| `vm_cpu` / `vm_ram` | 1..64 шт. / 1..256 ГБ |
| `vm_disk` | дополнительный диск, ГБ (основной диск зависит от образа) |
| `user_login` | учётка SSH (по умолчанию `myuser`) |
| `access_port_list` | `[{ "port": "22", "type": "tcp" }]`; `type` — `tcp`, `udp` или `all` |
| `access_ip_list` | белый список адресов; по умолчанию `["0.0.0.0/0"]` |
`image_vm`, `vm_name`, `user_login`, `user_public_key` и `vapp_uid` — **create-only** (менять нельзя).
`vm_cpu`, `vm_ram`, `vm_disk`, `access_port_list`, `ip_space_name` меняются операцией `modify`.
### Внешний доступ
| `ip_space_name` | Что это значит |
|---|---|
| `internet-ipv4-v1` | у ВМ есть внешний адрес: в `vm_state_flat` появятся `externalConnect` и `fqdn` |
| `no-needed` | внешнего адреса нет, доступ только изнутри |
Адрес берётся из квоты организации, поэтому при внешнем доступе нужен **4-й адрес** (`ip_count = "4"`).
При `same_snat = false` (по умолчанию) ВМ публикуется за общим SNAT шлюза.
Пример результата на живом стенде:
```
vm_state_flat = {
"externalConnect" = "185.247.187.235"
"internalConnect" = "10.10.102.4"
"fqdn" = "web01.85ea682f-...dev.nubes.ru"
}
```
### Что проверять при создании ВМ
1. **Запас vCPU в vDC.** Штурвал при настройках по умолчанию занимает 8 vCPU из 8 (2 ноды × «TKG 4CPU 8RAM»).
Тогда ВМ не включится: платформа отдаёт `[400:VALIDATION] Unable to perform this action` на этапе
power-on, а инстанс остаётся в статусе `not created`. Лечится увеличением `vdc_cpu_allocated`.
2. **Квота IP.** `ip_count ≥ 4`, иначе внешний адрес для ВМ не выделится.
3. **Ключ.** Публичный ключ кладите в `terraform.tfvars`, не в репозиторий.
4. **Время.** ВМ создаётся и включается с customization (запись учётки и ключа) — на живом стенде
операция шла больше 15 минут. В примере стоит `operation_timeout = "15m"`; при медленной платформе
увеличьте его. Симптом таймаута — «операция … не завершилась за установленный таймаут».
5. **Проверка.** `ssh <vm_user_login>@<externalConnect>` и `terraform plan` → `No changes`.
## 7. Удаление: «заморозка» вместо удаления
```bash
terraform destroy
```
По умолчанию пример повторяет рабочую конфигурацию — при `destroy` объекты **не удаляются**:
| Ресурс | Что делает `destroy` | Флаг в примере |
|---|---|---|
| Кластер Штурвал | `suspend`: выключается, данные и адреса сохраняются | `suspend_on_destroy = true` |
| vDC | `suspend` | `suspend_on_destroy = true` |
| Edge | не трогается: у эджа нет операции `suspend` | `keep_on_destroy = true` |
| SNAT | не выключается | `keep_on_destroy = true` |
| Квота внешних IP | не меняется: адреса держит кластер Штурвала | `keep_on_destroy = true` |
| vApp | `suspend`: выключаются все ВМ внутри каталога | `suspend_on_destroy = true` |
| ВМ | `suspend` | `suspend_on_destroy = true` |
Приоритет флагов: `keep_on_destroy` важнее `suspend_on_destroy`. Следующий `apply`
усыновит объекты по имени и разморозит кластер и vDC
(`adopt_existing_on_create = true` → `resume`). **Организация не удаляется никогда.**
Полное удаление — осознанно: поставьте `keep_on_destroy = false` и
`suspend_on_destroy = false` и удаляйте по порядку: кластер → ВМ → vApp → квота IP (`count = 0`) →
SNAT → Edge → vDC. Квоту нельзя опустить ниже занятых адресов, поэтому — только
после удаления кластера и ВМ; vApp удаляется лишь после `suspend` (полное удаление — через 14 дней),
а vDC — через 14 дней после `suspend`.
## Файлы примера
| Файл | Что делает |
|---|---|
| `versions.tf` | версия Terraform и провайдера Nubes |
| `provider.tf` | подключение к API (токен, адрес) |
| `variables.tf` | все параметры с описанием |
| `vdc.tf` | виртуальный датацентр |
| `edge.tf` | сетевой шлюз периметра (Edge) |
| `modifiers.tf` | внешние IP на организации + SNAT на шлюзе |
| `vm.tf` | vApp и ВМ: переменные vApp/ВМ, оба ресурса и выводы (отключается удалением файла) |
| `shturval.tf` | Kubernetes-кластер Штурвал: переменные Штурвала, группы воркеров и сам ресурс |
| `outputs.tf` | UUID и имена созданных услуг (vDC, Edge, vApp, ВМ, Штурвал) |
| `terraform.tfvars.example` | шаблон значений (копируется в `terraform.tfvars`) |
Подробнее про два последних ресурса — на странице
[«Ресурсы-модификаторы (IP организации, SNAT)»](../modifiers/org_ip_and_snat.md).
+142
View File
@@ -0,0 +1,142 @@
# Схема зависимостей облачных сервисов (docs/diagrams)
Папка содержит все артефакты схемы: исходники, отрендеренные изображения и
генератор. Здесь же описан процесс создания и результаты сверки с контрактами.
## 1. Состав папки
| Файл | Что это | Чем создаётся |
|---|---|---|
| `infra_services_flow.mmd` | исходник Mermaid-версии схемы | правится вручную |
| `infra_services_flow.svg` | вектор Mermaid-версии | рендер через `mermaid.ink` |
| `infra_services_flow.png` | растр Mermaid-версии | рендер через `mermaid.ink` |
| `render_infra_diagram.py` | генератор стилизованной схемы (PNG + SVG) | чистый Python + Pillow |
| `infra_services_diagram.png` | стилизованная схема, растр 1600×720 (рендер 2x + LANCZOS) | `render_infra_diagram.py` |
| `infra_services_diagram.svg` | стилизованная схема, вектор | `render_infra_diagram.py` |
Две версии существуют одновременно: Mermaid — для правок текстом и встраивания
в Markdown; стилизованная — как готовая картинка для показа.
## 2. Источник данных
Единственный источник фактов — YAML-контракты сервисов:
```
generated/dev/resources_yaml/*.yaml
```
Из них берутся:
- `service_id`, `service_display_name` — идентичность сервиса;
- `operations[].params[].ref_svc_id` — **прямая зависимость** от другого сервиса;
- `operations[].params[].depends_on` — зависимость порядка внутри операции;
- `value_list`, `default`, `man`, `descr` — значения на блоках (CPU, сети и т.п.);
- `service_man` — чек-листы и порядок действий, заданные самой платформой.
Terraform-файлы `DEV_STAND/*` **не используются как источник** для этой схемы —
они лишь один из примеров потребления.
## 3. Как перегенерировать
```bash
cd /home/naeel/TF/tf_provider
python3 docs/diagrams/render_infra_diagram.py
```
Скрипт пишет `infra_services_diagram.png` и `.svg` **в свою же папку**
(`docs/diagrams/`), путь вычисляется от расположения файла.
Требования: `python3` с `Pillow` и шрифты DejaVu
(`/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf`, `...-Bold.ttf`).
Сторонние библиотеки для SVG не нужны — SVG формируется текстом.
### Публикация картинок на сайте
Скрипт `TOOLS/scripts/04_build_and_publish_docs.sh` копирует **только картинки**
(`docs/diagrams/*.svg`, `*.png`) в публикуемый `docs_dir` → `diagrams/`: исходники (`.mmd`, `.py`)
на сайт не попадают. Поэтому картинку можно вставлять в опубликованные страницы относительной
ссылкой, например со страницы `docs/curated/pipeline/vdc_edge_ip_snat.md`:
```markdown
![Схема зависимостей облачных сервисов](../../diagrams/infra_services_diagram.svg)
```
Изменения в `docs/diagrams/` попадают на сайт только при следующей публикации документации.
Для Mermaid-версии нужен рендерер (`mermaid.ink`, `mmdc` или предпросмотр
Markdown в VS Code); в этом репозитории использовался `mermaid.ink` через
`python3` + `urllib`.
## 4. Структура картинки
Пять колонок = этапы создания, слева направо:
1. **Тенант** — Организация в Cloud Director (19)
2. **Пул ресурсов** — Виртуальный датацентр (21)
3. **Сетевой периметр** — Сетевой шлюз периметра / Edge (22)
4. **Среда запуска** — Каталог ВМ / vApp (26) + Внешние IP (vcOrg modify / 25)
5. **Потребители** — Виртуальная машина (28) и Kubernetes кластер Штурвал (150)
### Обозначения
- **Сплошная стрелка** — обязательная зависимость, ссылка на UUID существующего
инстанса (`ref_svc_id`).
- **Пунктирная стрелка** — выделение ресурса, операция `modify` или предусловие.
- Направление стрелки всегда **от зависимости к потребителю**: `Org → vDC`,
`vApp → VM`. То есть стрелка отвечает на вопрос «что должно существовать до».
- Цвет колонки кодирует этап, цвет рамки блока — группу (тенант / ресурсы / сеть /
потребители / адреса). Легенда — в правом нижнем углу картинки.
### Бейджи точек выделения ресурсов
| Бейдж | Где на схеме | Что означает |
|---|---|---|
| «здесь выделяются CPU / RAM / Диск» | блок vDC (21) | `cpuAllocated`, `memAllocated`, `storageConfig` |
| «SNAT настраивается операцией modify» | блок Edge (22) | SNAT — не сервис, а `modify` у Edge |
| «здесь выделяются IP» | блок «Внешние IP» | выделение адресов на организации |
| «требует ALB + VS ≥ 3» | блок Штурвала (150) | обязательное условие из чек-листа |
## 5. Результат сверки с YAML
Проверено построчно (коммит `6791e8f`, дополнение — связь `vApp → Внешние IP`).
| Утверждение на схеме | Источник |
|---|---|
| Организация: типы `iaas / saas` | `19_vc_org.yaml:47-53` |
| Выделение IP через `vcOrg modify (vIPConfigure)` | `19_vc_org.yaml:115` |
| Для внешних IP нужны vDC и Edge | `19_vc_org.yaml:112` (`man`) |
| vDC: `cpuGuaranteed: 0% / 50% / 80%` | `21_vc_vdc.yaml:71-78` |
| vDC: `cpuAllocated`, `memAllocated`, `storageConfig` | `21_vc_vdc.yaml:49,83,93` |
| Edge: AVI Load Balancer | `22_vc_nsxt.yaml` (`needEnableAVI`) |
| Edge: routed-сеть `10.10.102.0/24` | `22_vc_nsxt.yaml:5` |
| Edge: SNAT настраивается операцией `modify` | `22_vc_nsxt.yaml` (`modify → ipSpaceName`) |
| vApp → vDC (`vdcUid`), Edge (`nsxtUid`) | `26_vapp.yaml:38,56` |
| ВМ → vApp (`vappUid`), `vmCpu · vmRam · vmDisk` | `28_vc_vm_v3.yaml:39,49,59,69` |
| Штурвал: `vdcUid`, `nsxtUid`; `sizingPolicy · sizingDisk · count` | `150_k8s_sthutrval_cluster.yaml:41,47,132,141,148` |
| Публичные IP: NAT/DNAT через сервис 25 | `25_vcexternalip.yaml:41,57,100` |
| **Публичные IP требуют «Каталог» (vApp)** | `25_vcexternalip.yaml:5` (`service_man`) |
| Штурвал: обязательны ALB и VS ≥ 3; суммарно 3 внешних адреса | `150_k8s_sthutrval_cluster.yaml` (`service_man`, чек-лист) |
## 6. Известные ограничения
1. **Переиспользование инстансов** («vDC и Edge могут быть переиспользованы»)
не написано в YAML буквально — это следствие семантики ссылок на UUID
существующего инстанса. Уверенность средняя, не высокая.
2. Блок «Внешние IP» объединяет два механизма: выделение адресов на организации
(`vcOrg modify`) и правила NAT у сервиса 25 (`vcexternalip`). Они подписаны
раздельно внутри блока, но нарисованы одной вершиной.
3. Остальные 33 сервиса каталога на схеме не показаны — отрисованы только
инфраструктурные цепочки и их прямые потребители.
4. Схема — компоновка по контрактам, а не результат наблюдения за развёртыванием.
Фактический порядок и доступность нужно подтверждать отдельно.
## 7. История
| Дата | Изменение |
|---|---|
| 2026-09-26 | Создана Mermaid-версия (`infra_services_flow.*`) |
| 2026-09-26 | Исправлена связь «вычислительный пул»: от vDC, а не от внешних IP |
| 2026-09-26 | Добавлен генератор стилизованной схемы (`render_infra_diagram.py`) |
| 2026-09-26 | Убрана дублирующая подпись `modify: SNAT` у блока Edge |
| 2026-09-26 | Сверка с YAML; добавлена связь `vApp → Внешние IP` — предусловие сервиса 25 |
| 2026-09-26 | Все артефакты перенесены в `docs/diagrams/` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

+145
View File
@@ -0,0 +1,145 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="720" viewBox="0 0 1600 720" font-family="DejaVu Sans, Verdana, sans-serif">
<rect width="1600" height="720" fill="#FFFFFF"/>
<rect x="16" y="8" width="270" height="552" rx="12" fill="#E8F3FB"/>
<rect x="16" y="14" width="270" height="44" rx="8" fill="#1565C0"/>
<text x="151.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 1. Тенант</text>
<text x="151.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">базовый уровень изоляции</text>
<rect x="340" y="8" width="270" height="552" rx="12" fill="#FDF1E3"/>
<rect x="340" y="14" width="270" height="44" rx="8" fill="#E65100"/>
<text x="475.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 2. Пул ресурсов</text>
<text x="475.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">CPU / RAM / Storage</text>
<rect x="664" y="8" width="270" height="552" rx="12" fill="#EEEAF8"/>
<rect x="664" y="14" width="270" height="44" rx="8" fill="#4527A0"/>
<text x="799.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 3. Сетевой периметр</text>
<text x="799.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">маршрутизация и балансировка</text>
<rect x="988" y="8" width="270" height="552" rx="12" fill="#FDF4E3"/>
<rect x="988" y="14" width="270" height="44" rx="8" fill="#EF6C00"/>
<text x="1123.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 4. Среда запуска</text>
<text x="1123.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">контейнер и внешние адреса</text>
<rect x="1312" y="8" width="270" height="552" rx="12" fill="#E9F5EA"/>
<rect x="1312" y="14" width="270" height="44" rx="8" fill="#2E7D32"/>
<text x="1447.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 5. Потребители</text>
<text x="1447.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">что разворачивается в итоге</text>
<rect x="31" y="150" width="240" height="150" rx="10" fill="#FFFFFF" stroke="#0288D1" stroke-width="2"/>
<rect x="43" y="162" width="22" height="22" rx="5" fill="#0288D1"/>
<text x="73" y="162" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Организация</text>
<text x="73" y="182" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">в Cloud Director</text>
<text x="73" y="202" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 19 · vcOrg</text>
<circle cx="51" cy="231" r="2.4" fill="#0288D1"/>
<text x="61" y="224" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Типы: iaas / saas</text>
<rect x="355" y="130" width="240" height="230" rx="10" fill="#FFF3E0" stroke="#F57C00" stroke-width="2"/>
<rect x="367" y="142" width="22" height="22" rx="5" fill="#F57C00"/>
<text x="397" y="142" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Виртуальный</text>
<text x="397" y="162" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">датацентр (vDC)</text>
<text x="397" y="182" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 21 · vcVdc</text>
<circle cx="375" cy="211" r="2.4" fill="#F57C00"/>
<text x="385" y="204" font-size="10" fill="#2E3A45" dominant-baseline="hanging">cpuGuaranteed: 0% / 50% / 80%</text>
<circle cx="375" cy="231" r="2.4" fill="#F57C00"/>
<text x="385" y="224" font-size="10" fill="#2E3A45" dominant-baseline="hanging">cpuAllocated, memAllocated</text>
<circle cx="375" cy="251" r="2.4" fill="#F57C00"/>
<text x="385" y="244" font-size="10" fill="#2E3A45" dominant-baseline="hanging">storageConfig</text>
<rect x="367" y="318" width="216" height="30" rx="6" fill="#FFFFFF" stroke="#F57C00" stroke-width="1"/>
<text x="475.0" y="328" font-size="10" fill="#F57C00" text-anchor="middle" dominant-baseline="hanging">здесь выделяются CPU / RAM / Диск</text>
<rect x="679" y="120" width="240" height="200" rx="10" fill="#EDE7F6" stroke="#5E35B1" stroke-width="2"/>
<rect x="691" y="132" width="22" height="22" rx="5" fill="#5E35B1"/>
<text x="721" y="132" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Сетевой шлюз</text>
<text x="721" y="152" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">периметра (Edge)</text>
<text x="721" y="172" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 22 · vcNsxt</text>
<circle cx="699" cy="201" r="2.4" fill="#5E35B1"/>
<text x="709" y="194" font-size="10" fill="#2E3A45" dominant-baseline="hanging">AVI Load Balancer</text>
<circle cx="699" cy="221" r="2.4" fill="#5E35B1"/>
<text x="709" y="214" font-size="10" fill="#2E3A45" dominant-baseline="hanging">routed-сеть 10.10.102.0/24</text>
<rect x="691" y="278" width="216" height="30" rx="6" fill="#FFFFFF" stroke="#5E35B1" stroke-width="1"/>
<text x="799.0" y="288" font-size="10" fill="#5E35B1" text-anchor="middle" dominant-baseline="hanging">SNAT настраивается операцией modify</text>
<rect x="1003" y="100" width="240" height="110" rx="10" fill="#FFF3E0" stroke="#F57C00" stroke-width="2"/>
<rect x="1015" y="112" width="22" height="22" rx="5" fill="#F57C00"/>
<text x="1045" y="112" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Каталог ВМ (vApp)</text>
<text x="1045" y="132" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 26 · vapp</text>
<circle cx="1023" cy="161" r="2.4" fill="#F57C00"/>
<text x="1033" y="154" font-size="10" fill="#2E3A45" dominant-baseline="hanging">логический контейнер для ВМ</text>
<rect x="1003" y="245" width="180" height="150" rx="10" fill="#FAFAFA" stroke="#9E9E9E" stroke-width="2" stroke-dasharray="9 6"/>
<rect x="1015" y="257" width="22" height="22" rx="5" fill="#9E9E9E"/>
<text x="1045" y="257" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Внешние IP</text>
<text x="1045" y="277" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 25</text>
<circle cx="1023" cy="306" r="2.4" fill="#9E9E9E"/>
<text x="1033" y="299" font-size="10" fill="#2E3A45" dominant-baseline="hanging">выделение: vcOrg</text>
<circle cx="1023" cy="326" r="2.4" fill="#9E9E9E"/>
<text x="1033" y="319" font-size="10" fill="#2E3A45" dominant-baseline="hanging">NAT: сервис 25</text>
<circle cx="1023" cy="346" r="2.4" fill="#9E9E9E"/>
<text x="1033" y="339" font-size="10" fill="#2E3A45" dominant-baseline="hanging">квота IP (ipSpace)</text>
<rect x="1015" y="353" width="156" height="30" rx="6" fill="#FFFFFF" stroke="#9E9E9E" stroke-width="1"/>
<text x="1093.0" y="363" font-size="10" fill="#9E9E9E" text-anchor="middle" dominant-baseline="hanging">здесь выделяются IP</text>
<rect x="1327" y="100" width="240" height="120" rx="10" fill="#E8F5E9" stroke="#2E7D32" stroke-width="2"/>
<rect x="1339" y="112" width="22" height="22" rx="5" fill="#2E7D32"/>
<text x="1369" y="112" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Виртуальная машина</text>
<text x="1369" y="132" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 28 · vc_vm_v3</text>
<circle cx="1347" cy="161" r="2.4" fill="#2E7D32"/>
<text x="1357" y="154" font-size="10" fill="#2E3A45" dominant-baseline="hanging">vmCpu · vmRam · vmDisk</text>
<rect x="1327" y="255" width="240" height="170" rx="10" fill="#E8F5E9" stroke="#2E7D32" stroke-width="2"/>
<rect x="1339" y="267" width="22" height="22" rx="5" fill="#2E7D32"/>
<text x="1369" y="267" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Kubernetes кластер</text>
<text x="1369" y="287" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Штурвал</text>
<text x="1369" y="307" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 150</text>
<circle cx="1347" cy="336" r="2.4" fill="#2E7D32"/>
<text x="1357" y="329" font-size="10" fill="#2E3A45" dominant-baseline="hanging">sizingPolicy · sizingDisk</text>
<circle cx="1347" cy="356" r="2.4" fill="#2E7D32"/>
<text x="1357" y="349" font-size="10" fill="#2E3A45" dominant-baseline="hanging">· count</text>
<rect x="1339" y="383" width="216" height="30" rx="6" fill="#FFFFFF" stroke="#2E7D32" stroke-width="1"/>
<text x="1447.0" y="393" font-size="10" fill="#2E7D32" text-anchor="middle" dominant-baseline="hanging">требует ALB + VS ≥ 3</text>
<path d="M271 225 L355 225" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="355,225 346.0,228.78 346.0,221.22" fill="#455A64"/>
<text x="313" y="262" font-size="10" fill="#455A64" text-anchor="middle" dominant-baseline="middle" transform="rotate(-90 313 262)">organizationUid</text>
<path d="M595 200 L679 200" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="679,200 670.0,203.78 670.0,196.22" fill="#455A64"/>
<text x="637" y="186" font-size="10" fill="#455A64" dominant-baseline="hanging">vdcUid</text>
<path d="M500 130 L500 92 L961 92 L961 140 L1003 140" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="1003,140 994.0,143.78 994.0,136.22" fill="#455A64"/>
<text x="706" y="96" font-size="10" fill="#455A64" dominant-baseline="hanging">vdcUid</text>
<path d="M919 180 L1003 180" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="1003,180 994.0,183.78 994.0,176.22" fill="#455A64"/>
<text x="961" y="166" font-size="10" fill="#455A64" dominant-baseline="hanging">nsxtUid</text>
<path d="M1243 140 L1327 140" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="1327,140 1318.0,143.78 1318.0,136.22" fill="#455A64"/>
<text x="1285" y="126" font-size="10" fill="#455A64" dominant-baseline="hanging">vappUid</text>
<path d="M500 360 L500 470 L1447 470 L1447 425" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="1447,425 1450.78,434.0 1443.22,434.0" fill="#455A64"/>
<text x="1180" y="456" font-size="10" fill="#455A64" dominant-baseline="hanging">vdcUid</text>
<path d="M799 320 L799 505 L1467 505 L1467 425" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="1467,425 1470.78,434.0 1463.22,434.0" fill="#455A64"/>
<text x="1180" y="491" font-size="10" fill="#455A64" dominant-baseline="hanging">nsxtUid</text>
<path d="M151 300 L151 540 L1050 540 L1050 395" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1050,395 1053.78,404.0 1046.22,404.0" fill="#7A8794"/>
<text x="161" y="526" font-size="10" fill="#7A8794" dominant-baseline="hanging">modify: vIPConfigure</text>
<path d="M870 320 L870 400 L1140 400 L1140 395" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1140,395 1143.78,404.0 1136.22,404.0" fill="#7A8794"/>
<text x="880" y="386" font-size="10" fill="#7A8794" dominant-baseline="hanging">modify: SNAT</text>
<path d="M1050 210 L1050 245" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1050,245 1046.22,236.0 1053.78,236.0" fill="#7A8794"/>
<text x="1060" y="218" font-size="10" fill="#7A8794" dominant-baseline="hanging">требует Каталог</text>
<path d="M1183 270 L1255 270 L1255 160 L1327 160" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1327,160 1318.0,163.78 1318.0,156.22" fill="#7A8794"/>
<text x="1187" y="222" font-size="10" fill="#7A8794" dominant-baseline="hanging">внешний IP</text>
<path d="M1183 350 L1327 350" fill="none" stroke="#C62828" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1327,350 1318.0,353.78 1318.0,346.22" fill="#C62828"/>
<text x="1187" y="330" font-size="10" fill="#C62828" dominant-baseline="hanging">≥ 3 адреса</text>
<text x="24" y="590" font-size="14" fill="#2E3A45" dominant-baseline="hanging">&gt; Порядок создания: Организация → vDC → Edge → (vApp + внешние IP) → ВМ / Штурвал.</text>
<text x="24" y="616" font-size="14" fill="#2E3A45" dominant-baseline="hanging">&gt; SNAT — не отдельный сервис, а операция modify у Edge.</text>
<text x="24" y="642" font-size="14" fill="#2E3A45" dominant-baseline="hanging">&gt; vDC и Edge могут быть переиспользованы существующие.</text>
<path d="M980 606 L1040 606" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
<polygon points="1040,606 1031.0,609.78 1031.0,602.22" fill="#455A64"/>
<text x="1052" y="597" font-size="11" fill="#2E3A45" dominant-baseline="hanging">Сплошная линия — обязательная зависимость</text>
<text x="1052" y="613" font-size="10" fill="#7A8794" dominant-baseline="hanging">(ссылка на UUID существующего инстанса)</text>
<path d="M980 650 L1040 650" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1040,650 1031.0,653.78 1031.0,646.22" fill="#7A8794"/>
<text x="1052" y="641" font-size="11" fill="#2E3A45" dominant-baseline="hanging">Пунктир — выделение ресурса / modify / предусловие</text>
<rect x="980" y="684" width="12" height="12" rx="3" fill="#1565C0"/>
<text x="996" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Тенант</text>
<rect x="1038" y="684" width="12" height="12" rx="3" fill="#F57C00"/>
<text x="1054" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Ресурсы</text>
<rect x="1102" y="684" width="12" height="12" rx="3" fill="#5E35B1"/>
<text x="1118" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Сеть</text>
<rect x="1147" y="684" width="12" height="12" rx="3" fill="#2E7D32"/>
<text x="1163" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Потребители</text>
<rect x="1237" y="684" width="12" height="12" rx="3" fill="#9E9E9E"/>
<text x="1253" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Адреса</text>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

+43
View File
@@ -0,0 +1,43 @@
flowchart LR
classDef base fill:#e1f5fe,stroke:#0288d1,stroke-width:2px;
classDef net fill:#ede7f6,stroke:#5e35b1,stroke-width:2px;
classDef compute fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
classDef target fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
classDef note fill:#fafafa,stroke:#9e9e9e,stroke-width:1px,stroke-dasharray: 4 4;
subgraph S1 ["Шаг 1. Тенант"]
ORG["<b>Организация (vcOrg: 19)</b><br/>• Тип: iaas / saas"]:::base
end
subgraph S2 ["Шаг 2. Пул ресурсов"]
VDC["<b>Виртуальный ДЦ (vcVdc: 21)</b><br/>• cpuGuaranteed: 0% / 50% / 80%<br/>• Пул: cpuAllocated, memAllocated, storage"]:::compute
end
subgraph S3 ["Шаг 3. Сетевой периметр"]
EDGE["<b>Шлюз Edge (vcNsxt: 22)</b><br/>• AVI Load Balancer<br/>• routed-сеть 10.10.102.0/24"]:::net
end
subgraph S4 ["Шаг 4. Среда запуска и адреса"]
VAPP["<b>Каталог vApp (26)</b><br/>(контейнер для ВМ)"]:::compute
IP["<b>Внешние IP</b><br/>• vcOrg modify / 25<br/>• квота адресов (ipSpace)"]:::note
end
subgraph S5 ["Шаг 5. Потребители"]
VM["<b>Виртуальная машина (28)</b><br/>• vmCpu, vmRam, vmDisk"]:::target
SHTURVAL["<b>Кластер Штурвал (150)</b><br/>• sizingPolicy, sizingDisk, count"]:::target
end
ORG -->|"organizationUid"| VDC
VDC -->|"vdcUid"| EDGE
ORG -.->|"modify: vIPConfigure"| IP
EDGE -.->|"modify: SNAT (ipSpaceName)"| IP
VDC -->|"vdcUid"| VAPP
EDGE -->|"nsxtUid"| VAPP
VAPP -->|"vappUid"| VM
IP -.->|"внешний IP"| VM
VDC -->|"vdcUid"| SHTURVAL
EDGE -->|"nsxtUid"| SHTURVAL
IP -.->|"внешний IP (&gt;= 3)"| SHTURVAL
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="mermaid-svg" width="100%" xmlns="http://www.w3.org/2000/svg" class="flowchart" style="max-width: 2338.671875px;" viewBox="0 0 2338.671875 552" role="graphics-document document" aria-roledescription="flowchart-v2" xmlns:xlink="http://www.w3.org/1999/xlink"><style xmlns="http://www.w3.org/1999/xhtml">@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css");</style><style>#mermaid-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg .error-icon{fill:#552222;}#mermaid-svg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg .marker{fill:#333333;stroke:#333333;}#mermaid-svg .marker.cross{stroke:#333333;}#mermaid-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg p{margin:0;}#mermaid-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg .cluster-label text{fill:#333;}#mermaid-svg .cluster-label span{color:#333;}#mermaid-svg .cluster-label span p{background-color:transparent;}#mermaid-svg .label text,#mermaid-svg span{fill:#333;color:#333;}#mermaid-svg .node rect,#mermaid-svg .node circle,#mermaid-svg .node ellipse,#mermaid-svg .node polygon,#mermaid-svg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg .rough-node .label text,#mermaid-svg .node .label text,#mermaid-svg .image-shape .label,#mermaid-svg .icon-shape .label{text-anchor:middle;}#mermaid-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg .rough-node .label,#mermaid-svg .node .label,#mermaid-svg .image-shape .label,#mermaid-svg .icon-shape .label{text-align:center;}#mermaid-svg .node.clickable{cursor:pointer;}#mermaid-svg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg .arrowheadPath{fill:#333333;}#mermaid-svg .edgePath .path{stroke:#333333;stroke-width:1px;}#mermaid-svg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg .cluster text{fill:#333;}#mermaid-svg .cluster span{color:#333;}#mermaid-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg rect.text{fill:none;stroke-width:0;}#mermaid-svg .icon-shape,#mermaid-svg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg .icon-shape p,#mermaid-svg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg .icon-shape .label rect,#mermaid-svg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg .node .neo-node{stroke:#9370DB;}#mermaid-svg [data-look="neo"].node rect,#mermaid-svg [data-look="neo"].cluster rect,#mermaid-svg [data-look="neo"].node polygon{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].swimlane.cluster rect{filter:none;}#mermaid-svg [data-look="neo"].node path{stroke:#9370DB;stroke-width:1px;}#mermaid-svg [data-look="neo"].node .outer-path{filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].node .neo-line path{stroke:#9370DB;filter:none;}#mermaid-svg [data-look="neo"].node circle{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].node circle .state-start{fill:#000000;}#mermaid-svg [data-look="neo"].icon-shape .icon{fill:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].icon-shape .icon-neo path{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg .base&gt;*{fill:rgb(225, 245, 254)!important;stroke:rgb(2, 136, 209)!important;stroke-width:2px!important;}#mermaid-svg .base span{fill:rgb(225, 245, 254)!important;stroke:rgb(2, 136, 209)!important;stroke-width:2px!important;}#mermaid-svg .net&gt;*{fill:rgb(237, 231, 246)!important;stroke:rgb(94, 53, 177)!important;stroke-width:2px!important;}#mermaid-svg .net span{fill:rgb(237, 231, 246)!important;stroke:rgb(94, 53, 177)!important;stroke-width:2px!important;}#mermaid-svg .compute&gt;*{fill:rgb(255, 243, 224)!important;stroke:rgb(245, 124, 0)!important;stroke-width:2px!important;}#mermaid-svg .compute span{fill:rgb(255, 243, 224)!important;stroke:rgb(245, 124, 0)!important;stroke-width:2px!important;}#mermaid-svg .target&gt;*{fill:rgb(232, 245, 233)!important;stroke:rgb(46, 125, 50)!important;stroke-width:2px!important;}#mermaid-svg .target span{fill:rgb(232, 245, 233)!important;stroke:rgb(46, 125, 50)!important;stroke-width:2px!important;}#mermaid-svg .note&gt;*{fill:rgb(250, 250, 250)!important;stroke:rgb(158, 158, 158)!important;stroke-width:1px!important;stroke-dasharray:4,4!important;}#mermaid-svg .note span{fill:rgb(250, 250, 250)!important;stroke:rgb(158, 158, 158)!important;stroke-width:1px!important;stroke-dasharray:4,4!important;}</style><g><marker id="mermaid-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="mermaid-svg_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"><g class="cluster " id="mermaid-svg-S5" data-look="classic"><rect style="" x="2020.671875" y="60" width="310" height="449"/><g class="cluster-label " transform="translate(2101.4140625, 60)"><foreignObject width="148.515625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 5. Потребители</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S4" data-look="classic"><rect style="" x="1539.546875" y="37" width="298.9375" height="322"/><g class="cluster-label " transform="translate(1573.4921875, 37)"><foreignObject width="231.046875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 4. Среда запуска и адреса</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S3" data-look="classic"><rect style="" x="979.546875" y="98" width="310" height="276"/><g class="cluster-label " transform="translate(1039.9453125, 98)"><foreignObject width="189.203125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 3. Сетевой периметр</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S2" data-look="classic"><rect style="" x="471.03125" y="178" width="310" height="366"/><g class="cluster-label " transform="translate(549.703125, 178)"><foreignObject width="152.65625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 2. Пул ресурсов</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S1" data-look="classic"><rect style="" x="8" y="8" width="301.84375" height="403"/><g class="cluster-label " transform="translate(107.265625, 8)"><foreignObject width="103.3125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 1. Тенант</p></span></div></foreignObject></g></g></g><g class="edgePaths"><path d="M201.574,265L219.619,281.5C237.664,298,273.754,331,305.231,347.5C336.708,364,363.573,364,390.438,364C417.302,364,444.167,364,461.099,364C478.031,364,485.031,364,488.531,364L492.031,364" id="mermaid-svg-L_ORG_VDC_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_ORG_VDC_0" data-points="W3sieCI6MjAxLjU3MzcwOTIzOTEzMDQ0LCJ5IjoyNjV9LHsieCI6MzA5Ljg0Mzc1LCJ5IjozNjR9LHsieCI6MzkwLjQzNzUsInkiOjM2NH0seyJ4Ijo0NzEuMDMxMjUsInkiOjM2NH0seyJ4Ijo0OTYuMDMxMjUsInkiOjM2NH1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M740.311,277L747.098,271.833C753.884,266.667,767.458,256.333,790.787,251.167C814.117,246,847.203,246,880.289,246C913.375,246,946.461,246,966.504,246C986.547,246,993.547,246,997.047,246L1000.547,246" id="mermaid-svg-L_VDC_EDGE_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VDC_EDGE_0" data-points="W3sieCI6NzQwLjMxMDkxMTAxNjk0OTEsInkiOjI3N30seyJ4Ijo3ODEuMDMxMjUsInkiOjI0Nn0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6MjQ2fSx7IngiOjk3OS41NDY4NzUsInkiOjI0Nn0seyJ4IjoxMDA0LjU0Njg3NSwieSI6MjQ2fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M195.481,187L214.541,166.667C233.602,146.333,271.723,105.667,304.216,85.333C336.708,65,363.573,65,390.438,65C417.302,65,444.167,65,483.432,65C522.698,65,574.365,65,626.031,65C677.698,65,729.365,65,771.741,65C814.117,65,847.203,65,880.289,65C913.375,65,946.461,65,988.837,65C1031.214,65,1082.88,65,1134.547,65C1186.214,65,1237.88,65,1284.547,78.333C1331.214,91.667,1372.88,118.333,1414.547,131.667C1456.214,145,1497.88,145,1522.221,144.484C1546.561,143.968,1553.575,142.935,1557.082,142.419L1560.59,141.903" id="mermaid-svg-L_ORG_IP_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_ORG_IP_0" data-points="W3sieCI6MTk1LjQ4MDU5MDA2MjExMTgsInkiOjE4N30seyJ4IjozMDkuODQzNzUsInkiOjY1fSx7IngiOjM5MC40Mzc1LCJ5Ijo2NX0seyJ4Ijo0NzEuMDMxMjUsInkiOjY1fSx7IngiOjYyNi4wMzEyNSwieSI6NjV9LHsieCI6NzgxLjAzMTI1LCJ5Ijo2NX0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6NjV9LHsieCI6OTc5LjU0Njg3NSwieSI6NjV9LHsieCI6MTEzNC41NDY4NzUsInkiOjY1fSx7IngiOjEyODkuNTQ2ODc1LCJ5Ijo2NX0seyJ4IjoxNDE0LjU0Njg3NSwieSI6MTQ1fSx7IngiOjE1MzkuNTQ2ODc1LCJ5IjoxNDV9LHsieCI6MTU2NC41NDY4NzUsInkiOjE0MS4zMjAzMDEwNjYyNzY0fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1258.154,183L1263.387,180.333C1268.619,177.667,1279.083,172.333,1305.148,158.667C1331.214,145,1372.88,123,1414.547,112C1456.214,101,1497.88,101,1522.221,101.516C1546.561,102.032,1553.575,103.065,1557.082,103.581L1560.59,104.097" id="mermaid-svg-L_EDGE_IP_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_EDGE_IP_0" data-points="W3sieCI6MTI1OC4xNTQ0Njk5MzY3MDksInkiOjE4M30seyJ4IjoxMjg5LjU0Njg3NSwieSI6MTY3fSx7IngiOjE0MTQuNTQ2ODc1LCJ5IjoxMDF9LHsieCI6MTUzOS41NDY4NzUsInkiOjEwMX0seyJ4IjoxNTY0LjU0Njg3NSwieSI6MTA0LjY3OTY5ODkzMzcyMzZ9XQ==" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M756.031,399.226L760.198,400.355C764.365,401.484,772.698,403.742,793.408,404.871C814.117,406,847.203,406,880.289,406C913.375,406,946.461,406,988.837,406C1031.214,406,1082.88,406,1134.547,406C1186.214,406,1237.88,406,1284.547,390.333C1331.214,374.667,1372.88,343.333,1414.547,327.667C1456.214,312,1497.88,312,1525.684,310.741C1553.487,309.482,1567.428,306.964,1574.398,305.704L1581.368,304.445" id="mermaid-svg-L_VDC_VAPP_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VDC_VAPP_0" data-points="W3sieCI6NzU2LjAzMTI1LCJ5IjozOTkuMjI1ODA2NDUxNjEyOX0seyJ4Ijo3ODEuMDMxMjUsInkiOjQwNn0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6NDA2fSx7IngiOjk3OS41NDY4NzUsInkiOjQwNn0seyJ4IjoxMTM0LjU0Njg3NSwieSI6NDA2fSx7IngiOjEyODkuNTQ2ODc1LCJ5Ijo0MDZ9LHsieCI6MTQxNC41NDY4NzUsInkiOjMxMn0seyJ4IjoxNTM5LjU0Njg3NSwieSI6MzEyfSx7IngiOjE1ODUuMzA0Njg3NSwieSI6MzAzLjczNDMxOTQ2NDc3MTF9XQ==" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1264.547,224.194L1268.714,223.495C1272.88,222.796,1281.214,221.398,1306.214,220.699C1331.214,220,1372.88,220,1414.547,220C1456.214,220,1497.88,220,1528.067,224.067C1558.253,228.135,1576.96,236.27,1586.313,240.337L1595.666,244.405" id="mermaid-svg-L_EDGE_VAPP_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_EDGE_VAPP_0" data-points="W3sieCI6MTI2NC41NDY4NzUsInkiOjIyNC4xOTM1NDgzODcwOTY3N30seyJ4IjoxMjg5LjU0Njg3NSwieSI6MjIwfSx7IngiOjE0MTQuNTQ2ODc1LCJ5IjoyMjB9LHsieCI6MTUzOS41NDY4NzUsInkiOjIyMH0seyJ4IjoxNTk5LjMzNDM3NSwieSI6MjQ2fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1792.727,285L1800.353,285C1807.979,285,1823.232,285,1846.04,285C1868.849,285,1899.214,285,1929.578,285C1959.943,285,1990.307,285,2019.627,274.237C2048.947,263.474,2077.222,241.949,2091.36,231.186L2105.498,220.423" id="mermaid-svg-L_VAPP_VM_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VAPP_VM_0" data-points="W3sieCI6MTc5Mi43MjY1NjI1LCJ5IjoyODV9LHsieCI6MTgzOC40ODQzNzUsInkiOjI4NX0seyJ4IjoxOTI5LjU3ODEyNSwieSI6Mjg1fSx7IngiOjIwMjAuNjcxODc1LCJ5IjoyODV9LHsieCI6MjEwOC42ODAzNDk1NzYyNzEsInkiOjIxOH1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1813.484,123L1817.651,123C1821.818,123,1830.151,123,1849.5,123C1868.849,123,1899.214,123,1929.578,123C1959.943,123,1990.307,123,2009.015,124.001C2027.723,125.001,2034.773,127.003,2038.299,128.004L2041.824,129.004" id="mermaid-svg-L_IP_VM_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_IP_VM_0" data-points="W3sieCI6MTgxMy40ODQzNzUsInkiOjEyM30seyJ4IjoxODM4LjQ4NDM3NSwieSI6MTIzfSx7IngiOjE5MjkuNTc4MTI1LCJ5IjoxMjN9LHsieCI6MjAyMC42NzE4NzUsInkiOjEyM30seyJ4IjoyMDQ1LjY3MTg3NSwieSI6MTMwLjA5Njc3NDE5MzU0ODM4fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M756.031,441.161L760.198,443.634C764.365,446.108,772.698,451.054,793.408,453.527C814.117,456,847.203,456,880.289,456C913.375,456,946.461,456,988.837,456C1031.214,456,1082.88,456,1134.547,456C1186.214,456,1237.88,456,1284.547,456C1331.214,456,1372.88,456,1414.547,456C1456.214,456,1497.88,456,1543.625,456C1589.37,456,1639.193,456,1689.016,456C1738.839,456,1788.661,456,1828.755,456C1868.849,456,1899.214,456,1929.578,456C1959.943,456,1990.307,456,2011.337,453.434C2032.368,450.869,2044.063,445.738,2049.911,443.172L2055.759,440.607" id="mermaid-svg-L_VDC_SHTURVAL_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VDC_SHTURVAL_0" data-points="W3sieCI6NzU2LjAzMTI1LCJ5Ijo0NDEuMTYxMjkwMzIyNTgwNn0seyJ4Ijo3ODEuMDMxMjUsInkiOjQ1Nn0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6NDU2fSx7IngiOjk3OS41NDY4NzUsInkiOjQ1Nn0seyJ4IjoxMTM0LjU0Njg3NSwieSI6NDU2fSx7IngiOjEyODkuNTQ2ODc1LCJ5Ijo0NTZ9LHsieCI6MTQxNC41NDY4NzUsInkiOjQ1Nn0seyJ4IjoxNTM5LjU0Njg3NSwieSI6NDU2fSx7IngiOjE2ODkuMDE1NjI1LCJ5Ijo0NTZ9LHsieCI6MTgzOC40ODQzNzUsInkiOjQ1Nn0seyJ4IjoxOTI5LjU3ODEyNSwieSI6NDU2fSx7IngiOjIwMjAuNjcxODc1LCJ5Ijo0NTZ9LHsieCI6MjA1OS40MjE4NzUsInkiOjQzOX1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1264.547,303.871L1268.714,305.726C1272.88,307.581,1281.214,311.29,1306.214,325.812C1331.214,340.333,1372.88,365.667,1414.547,378.333C1456.214,391,1497.88,391,1543.625,391C1589.37,391,1639.193,391,1689.016,391C1738.839,391,1788.661,391,1828.755,391C1868.849,391,1899.214,391,1929.578,391C1959.943,391,1990.307,391,2008.99,390.932C2027.672,390.865,2034.672,390.729,2038.172,390.661L2041.673,390.594" id="mermaid-svg-L_EDGE_SHTURVAL_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_EDGE_SHTURVAL_0" data-points="W3sieCI6MTI2NC41NDY4NzUsInkiOjMwMy44NzA5Njc3NDE5MzU1fSx7IngiOjEyODkuNTQ2ODc1LCJ5IjozMTV9LHsieCI6MTQxNC41NDY4NzUsInkiOjM5MX0seyJ4IjoxNTM5LjU0Njg3NSwieSI6MzkxfSx7IngiOjE2ODkuMDE1NjI1LCJ5IjozOTF9LHsieCI6MTgzOC40ODQzNzUsInkiOjM5MX0seyJ4IjoxOTI5LjU3ODEyNSwieSI6MzkxfSx7IngiOjIwMjAuNjcxODc1LCJ5IjozOTF9LHsieCI6MjA0NS42NzE4NzUsInkiOjM5MC41MTYxMjkwMzIyNTgwNX1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1725.143,174L1744.033,200.667C1762.924,227.333,1800.704,280.667,1834.776,307.333C1868.849,334,1899.214,334,1929.578,334C1959.943,334,1990.307,334,2009.027,335.232C2027.746,336.465,2034.82,338.929,2038.357,340.161L2041.895,341.394" id="mermaid-svg-L_IP_SHTURVAL_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_IP_SHTURVAL_0" data-points="W3sieCI6MTcyNS4xNDMxNDI3NzI1MTIsInkiOjE3NH0seyJ4IjoxODM4LjQ4NDM3NSwieSI6MzM0fSx7IngiOjE5MjkuNTc4MTI1LCJ5IjozMzR9LHsieCI6MjAyMC42NzE4NzUsInkiOjMzNH0seyJ4IjoyMDQ1LjY3MTg3NSwieSI6MzQyLjcwOTY3NzQxOTM1NDh9XQ==" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(390.4375, 364)"><g class="label" data-id="L_ORG_VDC_0" transform="translate(-55.59375, -12)"><foreignObject width="111.1875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>organizationUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(880.2890625, 246)"><g class="label" data-id="L_VDC_EDGE_0" transform="translate(-24.453125, -12)"><foreignObject width="48.90625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vdcUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(880.2890625, 65)"><g class="label" data-id="L_ORG_IP_0" transform="translate(-74.2578125, -12)"><foreignObject width="148.515625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>modify: vIPConfigure</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1414.546875, 101)"><g class="label" data-id="L_EDGE_IP_0" transform="translate(-100, -24)"><foreignObject width="200" height="48"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="edgeLabel "><p>modify: SNAT (ipSpaceName)</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1134.546875, 406)"><g class="label" data-id="L_VDC_VAPP_0" transform="translate(-24.453125, -12)"><foreignObject width="48.90625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vdcUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1414.546875, 220)"><g class="label" data-id="L_EDGE_VAPP_0" transform="translate(-26.6796875, -12)"><foreignObject width="53.359375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>nsxtUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1929.578125, 285)"><g class="label" data-id="L_VAPP_VM_0" transform="translate(-29.3515625, -12)"><foreignObject width="58.703125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vappUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1929.578125, 123)"><g class="label" data-id="L_IP_VM_0" transform="translate(-42.671875, -12)"><foreignObject width="85.34375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>внешний IP</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1414.546875, 456)"><g class="label" data-id="L_VDC_SHTURVAL_0" transform="translate(-24.453125, -12)"><foreignObject width="48.90625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vdcUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1689.015625, 391)"><g class="label" data-id="L_EDGE_SHTURVAL_0" transform="translate(-26.6796875, -12)"><foreignObject width="53.359375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>nsxtUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1929.578125, 334)"><g class="label" data-id="L_IP_SHTURVAL_0" transform="translate(-66.09375, -12)"><foreignObject width="132.1875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>внешний IP (&gt;= 3)</p></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default base " id="mermaid-svg-flowchart-ORG-0" data-look="classic" transform="translate(158.921875, 226)"><rect class="basic label-container" style="fill:#e1f5fe !important;stroke:#0288d1 !important;stroke-width:2px !important" x="-125.921875" y="-39" width="251.84375" height="78"/><g class="label" style="" transform="translate(-95.921875, -24)"><rect/><foreignObject width="191.84375" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel "><p><b>Организация (vcOrg: 19)</b><br />• Тип: iaas / saas</p></span></div></foreignObject></g></g><g class="node default compute " id="mermaid-svg-flowchart-VDC-1" data-look="classic" transform="translate(626.03125, 364)"><rect class="basic label-container" style="fill:#fff3e0 !important;stroke:#f57c00 !important;stroke-width:2px !important" x="-130" y="-87" width="260" height="174"/><g class="label" style="" transform="translate(-100, -72)"><rect/><foreignObject width="200" height="144"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Виртуальный ДЦ (vcVdc: 21)</b><br />• cpuGuaranteed: 0% / 50% / 80%<br />• Пул: cpuAllocated, memAllocated, storage</p></span></div></foreignObject></g></g><g class="node default net " id="mermaid-svg-flowchart-EDGE-2" data-look="classic" transform="translate(1134.546875, 246)"><rect class="basic label-container" style="fill:#ede7f6 !important;stroke:#5e35b1 !important;stroke-width:2px !important" x="-130" y="-63" width="260" height="126"/><g class="label" style="" transform="translate(-100, -48)"><rect/><foreignObject width="200" height="96"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Шлюз Edge (vcNsxt: 22)</b><br />• AVI Load Balancer<br />• routed-сеть 10.10.102.0/24</p></span></div></foreignObject></g></g><g class="node default compute " id="mermaid-svg-flowchart-VAPP-3" data-look="classic" transform="translate(1689.015625, 285)"><rect class="basic label-container" style="fill:#fff3e0 !important;stroke:#f57c00 !important;stroke-width:2px !important" x="-103.7109375" y="-39" width="207.421875" height="78"/><g class="label" style="" transform="translate(-73.7109375, -24)"><rect/><foreignObject width="147.421875" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel "><p><b>Каталог vApp (26)</b><br />(контейнер для ВМ)</p></span></div></foreignObject></g></g><g class="node default note " id="mermaid-svg-flowchart-IP-4" data-look="classic" transform="translate(1689.015625, 123)"><rect class="basic label-container" style="fill:#fafafa !important;stroke:#9e9e9e !important;stroke-width:1px !important;stroke-dasharray:4 4 !important" x="-124.46875" y="-51" width="248.9375" height="102"/><g class="label" style="" transform="translate(-94.46875, -36)"><rect/><foreignObject width="188.9375" height="72"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel "><p><b>Внешние IP</b><br />• vcOrg modify / 25<br />• квота адресов (ipSpace)</p></span></div></foreignObject></g></g><g class="node default target " id="mermaid-svg-flowchart-VM-5" data-look="classic" transform="translate(2175.671875, 167)"><rect class="basic label-container" style="fill:#e8f5e9 !important;stroke:#2e7d32 !important;stroke-width:2px !important" x="-130" y="-51" width="260" height="102"/><g class="label" style="" transform="translate(-100, -36)"><rect/><foreignObject width="200" height="72"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Виртуальная машина (28)</b><br />• vmCpu, vmRam, vmDisk</p></span></div></foreignObject></g></g><g class="node default target " id="mermaid-svg-flowchart-SHTURVAL-6" data-look="classic" transform="translate(2175.671875, 388)"><rect class="basic label-container" style="fill:#e8f5e9 !important;stroke:#2e7d32 !important;stroke-width:2px !important" x="-130" y="-51" width="260" height="102"/><g class="label" style="" transform="translate(-100, -36)"><rect/><foreignObject width="200" height="72"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Кластер Штурвал (150)</b><br />• sizingPolicy, sizingDisk, count</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="mermaid-svg-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="mermaid-svg-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs></svg>

After

Width:  |  Height:  |  Size: 32 KiB

+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Генератор схемы зависимостей облачных сервисов (PNG + SVG).
Содержание подтверждено YAML-контрактами generated/dev/resources_yaml.
PNG рисуется в 2x и уменьшается (сглаживание), SVG — вектор.
"""
import os
from PIL import Image, ImageDraw, ImageFont
W, H = 1600, 720
SS = 2 # supersample
FONT_R = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
FONT_B = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
DARK = "#1A1A1A"
GRAY = "#5F6B76"
MUTED = "#7A8794"
TEXT = "#2E3A45"
RED = "#C62828"
LINE = "#455A64"
COLS = [
(16, "Шаг 1. Тенант", "базовый уровень изоляции", "#1565C0", "#E8F3FB"),
(340, "Шаг 2. Пул ресурсов", "CPU / RAM / Storage", "#E65100", "#FDF1E3"),
(664, "Шаг 3. Сетевой периметр", "маршрутизация и балансировка", "#4527A0", "#EEEAF8"),
(988, "Шаг 4. Среда запуска", "контейнер и внешние адреса", "#EF6C00", "#FDF4E3"),
(1312, "Шаг 5. Потребители", "что разворачивается в итоге", "#2E7D32", "#E9F5EA"),
]
COL_W = 270
# блоки: (x, y, w, h, заголовок-строки, подпись, строки, бейдж, рамка, фон, пунктир)
BLOCKS = [
(31, 150, 240, 150, ["Организация", "в Cloud Director"], "Сервис 19 · vcOrg",
["Типы: iaas / saas"], None, "#0288D1", "#FFFFFF", False),
(355, 130, 240, 230, ["Виртуальный", "датацентр (vDC)"], "Сервис 21 · vcVdc",
["cpuGuaranteed: 0% / 50% / 80%", "cpuAllocated, memAllocated", "storageConfig"],
"здесь выделяются CPU / RAM / Диск", "#F57C00", "#FFF3E0", False),
(679, 120, 240, 200, ["Сетевой шлюз", "периметра (Edge)"], "Сервис 22 · vcNsxt",
["AVI Load Balancer", "routed-сеть 10.10.102.0/24"],
"SNAT настраивается операцией modify", "#5E35B1", "#EDE7F6", False),
(1003, 100, 240, 110, ["Каталог ВМ (vApp)"], "Сервис 26 · vapp",
["логический контейнер для ВМ"], None, "#F57C00", "#FFF3E0", False),
(1003, 245, 180, 150, ["Внешние IP"], "Сервис 25",
["выделение: vcOrg", "NAT: сервис 25", "квота IP (ipSpace)"],
"здесь выделяются IP", "#9E9E9E", "#FAFAFA", True),
(1327, 100, 240, 120, ["Виртуальная машина"], "Сервис 28 · vc_vm_v3",
["vmCpu · vmRam · vmDisk"], None, "#2E7D32", "#E8F5E9", False),
(1327, 255, 240, 170, ["Kubernetes кластер", "Штурвал"], "Сервис 150",
["sizingPolicy · sizingDisk", "· count"], "требует ALB + VS ≥ 3",
"#2E7D32", "#E8F5E9", False),
]
# связи: (точки, цвет, толщина, пунктир, подпись, позиция подписи, вертикальная)
EDGES = [
([(271, 225), (355, 225)], LINE, 2, False, "organizationUid", (313, 262), True),
([(595, 200), (679, 200)], LINE, 2, False, "vdcUid", (637, 186), False),
([(500, 130), (500, 92), (961, 92), (961, 140), (1003, 140)], LINE, 2, False,
"vdcUid", (706, 96), False),
([(919, 180), (1003, 180)], LINE, 2, False, "nsxtUid", (961, 166), False),
([(1243, 140), (1327, 140)], LINE, 2, False, "vappUid", (1285, 126), False),
([(500, 360), (500, 470), (1447, 470), (1447, 425)], LINE, 2, False,
"vdcUid", (1180, 456), False),
([(799, 320), (799, 505), (1467, 505), (1467, 425)], LINE, 2, False,
"nsxtUid", (1180, 491), False),
([(151, 300), (151, 540), (1050, 540), (1050, 395)], MUTED, 2, True,
"modify: vIPConfigure", (161, 526), False),
([(870, 320), (870, 400), (1140, 400), (1140, 395)], MUTED, 2, True,
"modify: SNAT", (880, 386), False),
([(1050, 210), (1050, 245)], MUTED, 2, True, "требует Каталог", (1060, 218), False),
([(1183, 270), (1255, 270), (1255, 160), (1327, 160)], MUTED, 2, True,
"внешний IP", (1187, 222), False),
([(1183, 350), (1327, 350)], RED, 2, True, "≥ 3 адреса", (1187, 330), False),
]
CAPTION = [
"Порядок создания: Организация → vDC → Edge → (vApp + внешние IP) → ВМ / Штурвал.",
"SNAT — не отдельный сервис, а операция modify у Edge.",
"vDC и Edge могут быть переиспользованы существующие.",
]
def op_rect(x, y, w, h, fill, outline, lw=2, r=10, dash=False):
return ("rect", (x, y, w, h, fill, outline, lw, r, dash))
def op_text(x, y, s, size, color, bold=False, anchor="lt", vertical=False):
return ("text", (x, y, s, size, color, bold, anchor, vertical))
def op_path(pts, color, lw, dash):
return ("path", (pts, color, lw, dash))
def op_dot(x, y, r, color):
return ("dot", (x, y, r, color))
def build():
ops = []
for cx, title, sub, accent, tint in COLS:
ops.append(op_rect(cx, 8, COL_W, 552, tint, "#FFFFFF", 0, 12))
ops.append(op_rect(cx, 14, COL_W, 44, accent, accent, 0, 8))
ops.append(op_text(cx + COL_W / 2, 25, title, 15, "#FFFFFF", True, "mt"))
ops.append(op_text(cx + COL_W / 2, 68, sub, 12, MUTED, False, "mt"))
for x, y, w, h, titles, sub, lines, badge, border, fill, dashed in BLOCKS:
ops.append(op_rect(x, y, w, h, fill, border, 2, 10, dashed))
ops.append(op_rect(x + 12, y + 12, 22, 22, border, border, 0, 5))
tx = x + 42
ty = y + 12
for i, t in enumerate(titles):
ops.append(op_text(tx, ty + i * 20, t, 15, DARK, True))
sy = ty + len(titles) * 20
ops.append(op_text(tx, sy, sub, 11, GRAY))
ly = sy + 22
for ln in lines:
ops.append(op_dot(x + 20, ly + 7, 2.4, border))
ops.append(op_text(x + 30, ly, ln, 10, TEXT))
ly += 20
if badge:
ops.append(op_rect(x + 12, y + h - 42, w - 24, 30, "#FFFFFF", border, 1, 6))
ops.append(op_text(x + w / 2, y + h - 32, badge, 10, border, False, "mt"))
for pts, color, lw, dash, label, pos, vertical in EDGES:
ops.append(op_path(pts, color, lw, dash))
ops.append(op_text(pos[0], pos[1], label, 10, color, False,
"lt" if not vertical else "lt", vertical))
for i, s in enumerate(CAPTION):
ops.append(op_text(24, 590 + i * 26, "> " + s, 14, TEXT))
ops.append(op_path([(980, 606), (1040, 606)], LINE, 2, False))
ops.append(op_text(1052, 597, "Сплошная линия — обязательная зависимость", 11, TEXT))
ops.append(op_text(1052, 613, "(ссылка на UUID существующего инстанса)", 10, MUTED))
ops.append(op_path([(980, 650), (1040, 650)], MUTED, 2, True))
ops.append(op_text(1052, 641, "Пунктир — выделение ресурса / modify / предусловие", 11, TEXT))
swatches = [("#1565C0", "Тенант"), ("#F57C00", "Ресурсы"), ("#5E35B1", "Сеть"),
("#2E7D32", "Потребители"), ("#9E9E9E", "Адреса")]
sx = 980
for color, name in swatches:
ops.append(op_rect(sx, 684, 12, 12, color, color, 0, 3))
ops.append(op_text(sx + 16, 682, name, 10, TEXT))
sx += 20 + int(len(name) * 6.4)
return ops
def accent_of(border):
return border
def dash_segments(p1, p2, on=9, off=6):
(x1, y1), (x2, y2) = p1, p2
segs, total = [], ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
if total == 0:
return segs
ux, uy = (x2 - x1) / total, (y2 - y1) / total
pos = 0.0
while pos < total:
end = min(pos + on, total)
segs.append(((x1 + ux * pos, y1 + uy * pos), (x1 + ux * end, y1 + uy * end)))
pos = end + off
return segs
def arrow_head(d, tip, tail, color, size=9):
(tx, ty), (px, py) = tip, tail
dx, dy = tx - px, ty - py
n = (dx * dx + dy * dy) ** 0.5 or 1
ux, uy = dx / n, dy / n
bx, by = tx - ux * size, ty - uy * size
ox, oy = -uy * size * 0.42, ux * size * 0.42
d.polygon([(tx, ty), (bx + ox, by + oy), (bx - ox, by - oy)], fill=color)
def draw_polyline(d, pts, color, lw, dash):
for a, b in zip(pts, pts[1:]):
segs = dash_segments(a, b) if dash else [(a, b)]
for s in segs:
d.line([s[0], s[1]], fill=color, width=lw)
if len(pts) >= 2:
tail = pts[-2]
tip = pts[-1]
dx, dy = tip[0] - tail[0], tip[1] - tail[1]
n = (dx * dx + dy * dy) ** 0.5 or 1
shrink = 9
tip2 = (tip[0] - dx / n * shrink, tip[1] - dy / n * shrink)
d.line([tail, tip2], fill=color, width=lw)
arrow_head(d, tip, tail, color)
def render_png(ops, path):
img = Image.new("RGB", (W * SS, H * SS), "#FFFFFF")
d = ImageDraw.Draw(img)
fonts = {}
def fnt(size, bold):
key = (size, bold)
if key not in fonts:
fonts[key] = ImageFont.truetype(FONT_B if bold else FONT_R, size * SS)
return fonts[key]
for kind, a in ops:
if kind == "rect":
x, y, w, h, fill, outline, lw, r, dashed = a
box = [x * SS, y * SS, (x + w) * SS, (y + h) * SS]
if dashed:
d.rounded_rectangle(box, radius=r * SS, fill=fill)
for a1, b1 in [((x, y), (x + w, y)), ((x + w, y), (x + w, y + h)),
((x + w, y + h), (x, y + h)), ((x, y + h), (x, y))]:
for s in dash_segments((a1[0] * SS, a1[1] * SS), (b1[0] * SS, b1[1] * SS),
on=8 * SS, off=6 * SS):
d.line([s[0], s[1]], fill=outline, width=max(1, lw * SS))
else:
d.rounded_rectangle(box, radius=r * SS, fill=fill,
outline=outline if lw else None,
width=max(1, lw * SS) if lw else 0)
elif kind == "text":
x, y, s, size, color, bold, anchor, vertical = a
f = fnt(size, bold)
if vertical:
bbox = f.getbbox(s)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tmp = Image.new("RGBA", (tw + 4, th + 4), (0, 0, 0, 0))
ImageDraw.Draw(tmp).text((-bbox[0] + 2, -bbox[1] + 2), s, font=f, fill=color)
tmp = tmp.rotate(90, expand=True)
img.paste(tmp, (int(x * SS - tmp.width / 2), int(y * SS - tmp.height / 2)), tmp)
else:
d.text((x * SS, y * SS), s, font=f, fill=color, anchor=anchor)
elif kind == "path":
pts, color, lw, dash = a
draw_polyline(d, [(px * SS, py * SS) for px, py in pts], color,
max(1, lw * SS), dash)
elif kind == "dot":
x, y, r, color = a
d.ellipse([(x - r) * SS, (y - r) * SS, (x + r) * SS, (y + r) * SS], fill=color)
img = img.resize((W, H), Image.LANCZOS)
img.save(path)
return path
def render_svg(ops, path):
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
f'viewBox="0 0 {W} {H}" font-family="DejaVu Sans, Verdana, sans-serif">',
f'<rect width="{W}" height="{H}" fill="#FFFFFF"/>']
def esc(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
for kind, a in ops:
if kind == "rect":
x, y, w, h, fill, outline, lw, r, dashed = a
stroke = f' stroke="{outline}" stroke-width="{lw}"' if lw else ""
dash = ' stroke-dasharray="9 6"' if dashed else ""
out.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" '
f'fill="{fill}"{stroke}{dash}/>')
elif kind == "text":
x, y, s, size, color, bold, anchor, vertical = a
weight = ' font-weight="bold"' if bold else ""
if vertical:
ta = ' text-anchor="middle" dominant-baseline="middle"'
transform = f' transform="rotate(-90 {x} {y})"'
else:
ta = (' text-anchor="middle" dominant-baseline="hanging"' if anchor == "mt"
else ' dominant-baseline="hanging"')
transform = ""
out.append(f'<text x="{x}" y="{y}" font-size="{size}" fill="{color}"'
f'{weight}{ta}{transform}>{esc(s)}</text>')
elif kind == "path":
pts, color, lw, dash = a
d = " ".join(("M" if i == 0 else "L") + f"{p[0]} {p[1]}"
for i, p in enumerate(pts))
dash = ' stroke-dasharray="9 6"' if dash else ""
out.append(f'<path d="{d}" fill="none" stroke="{color}" '
f'stroke-width="{lw}"{dash} stroke-linejoin="round"/>')
(tx, ty), (px, py) = pts[-1], pts[-2]
dx, dy = tx - px, ty - py
n = (dx * dx + dy * dy) ** 0.5 or 1
ux, uy = dx / n, dy / n
s_ = 9
bx, by = tx - ux * s_, ty - uy * s_
ox, oy = -uy * s_ * 0.42, ux * s_ * 0.42
out.append(f'<polygon points="{tx},{ty} {bx + ox},{by + oy} '
f'{bx - ox},{by - oy}" fill="{color}"/>')
elif kind == "dot":
x, y, r, color = a
out.append(f'<circle cx="{x}" cy="{y}" r="{r}" fill="{color}"/>')
out.append("</svg>")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(out))
return path
if __name__ == "__main__":
# Файлы кладём рядом со скриптом: docs/diagrams/
base = os.path.dirname(os.path.abspath(__file__))
ops = build()
print(render_png(ops, os.path.join(base, "infra_services_diagram.png")))
print(render_svg(ops, os.path.join(base, "infra_services_diagram.svg")))
+3
View File
@@ -56,7 +56,10 @@ nav:
- Руководства:
- Быстрый старт: 30_registry/guides/getting-started.md
- Основы Terraform: 30_registry/guides/terraform-basics.md
- Как работает провайдер (отличия от Terraform): 30_registry/guides/provider-behavior.md
- Структура манифестов: 30_registry/guides/terraform-structure.md
- Глоссарий: 30_registry/guides/glossary.md
- Проверенные примеры:
- PostgreSQL: curated/postgres/pg_user_db.md
- Пайплайн vDC → Edge → IP → SNAT → vApp → ВМ → Штурвал: 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 (
"bytes"
"encoding/json"
"regexp"
"sort"
"strconv"
"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.
func JSONStringsEquivalent(a, b string) bool {
na, oka := normalizeJSONIfPossible(a)
@@ -107,7 +124,7 @@ func normalizeJSONScalarsToStrings(v interface{}) interface{} {
}
return out
case string:
return val
return LowercaseUUIDsInText(val)
case float64:
return strconv.FormatFloat(val, 'g', -1, 64)
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"
// 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
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 {
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
}
@@ -17,6 +17,8 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-nubes/internal/core/jsonutil"
)
// jsonNormalizePlanModifier реализует planmodifier.String.
@@ -49,5 +51,7 @@ func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planm
// Невалидный JSON — оставляем значение без изменений, не прерываем планирование.
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 — неверно")
}
}