Author SHA1 Message Date
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
Repinoid f7fffb9ed7 refactor: разложить рабочие материалы по NOTES/ и HOW_TO/, корневой README — карта проекта
- NOTES/: 10_plans, 20_prompts, 30_analysis, 40_chat_summaries, 60_reference + README в каждой папке
- HOW_TO/: все общие инструкции (сборка/заливка, DevOps-ранбук, добавление сервиса, миграция, генерация доков) + индекс «что нужно -> какой файл»
- новый README.md: карта проекта, пайплайн, стенды, реестр, запреты/грабли
- HOWTO-UPLOAD.md: исправлена легаси-схема версий (prod=1.*, dev=2.*, test=3.*)
- DEVOPS_BUILD_PIPELINE.md: пути скриптов -> TOOLS/scripts, universal_rebuild/main.go -> provider/main.go
- howitwasdone.md / MIGRATION_PLAN_FOR_AGENT.md: пометки о соответствии старых путей
- внутри перенесённых файлов обновлены ссылки на новые пути
2026-09-24 07:51:38 +03:00
Repinoid 2d8e435dd4 docs: пометить отменённый заход модификаторов как LEGACY + исправить ложные факты
- баннеры «ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО» на 4 файла HISTORY/OPUS/2026-09-22_modifier_* и docs/60_strategy/modifier_resources_ideology_and_specification.md
- vIPConfigure: replace-семантика, НЕ накопительная (по тесту docs/ORG_IP_MODIFIER_TEST_2026-09-22.md)
- обновлены ссылки на перенесённые материалы (docs/... -> NOTES/..., HOW_TO/...)
2026-09-24 07:51:25 +03:00
Repinoid d93ff66482 chore: save current changes 2026-09-24 07:25:27 +03:00
Repinoid a96e38fb1e chore: save current changes 2026-09-23 19:23:31 +03:00
Repinoid d78573de45 refactor(fullpipe): убрать зависимость от модификаторов (org_ips.tf, edge_network.tf) — чистые ресурсы vdc+nsxt 2026-09-23 08:55:26 +03:00
Repinoid eaa4593622 chore(fullpipe): bump провайдера 2.0.16 -> 2.0.17 (чистая генерация без модификаторов) 2026-09-23 08:51:01 +03:00
Repinoid a5a5f83730 chore(dev): bump провайдера 2.0.16 -> 2.0.17 (чистая генерация без модификаторов) 2026-09-23 08:46:32 +03:00
Repinoid 20ea79a489 docs(modifier): анализ inverse-архитектуры + промпты Опусу (глобальная архитектура оверлея) 2026-09-23 08:43:56 +03:00
Repinoid d5dc1212ba refactor(yaml-gen): убрать вплетение модификаторов — YAML = чистая полная выгрузка из API 2026-09-23 08:42:01 +03:00
Repinoid 016b7d246a chore(fullpipe): bump провайдера 2.0.15 -> 2.0.16 2026-09-23 07:11:12 +03:00
Repinoid 200bf457ce fix(gen): Update без modify перечитывает read-back поля через RefreshResourceState (устраняет устаревший state_params/ложный дрейф) 2026-09-23 07:04:02 +03:00
Repinoid 60bf218532 chore(fullpipe): bump провайдера 2.0.14 -> 2.0.15 2026-09-22 22:40:16 +03:00
Repinoid b9fa18164c fix(core,gen): UseStateForUnknown для Optional+Computed + live-dосылка без тихого fallback (R1+R3+A/B) 2026-09-22 22:35:45 +03:00
Repinoid 0473f80f69 chore(fullpipe): bump провайдера 2.0.13 -> 2.0.14 2026-09-22 22:10:28 +03:00
Repinoid f62a094043 fix(fullpipe): ALB/VS/qos задаются в модификаторе vc_nsxt_network (resource vc_nsxt Update — no-op) 2026-09-22 22:07:14 +03:00
Repinoid a011358660 fix(core): досылать незаданные modify-params из live state.params (а не paramValue формы) — устраняет сброс needEnableAVI/ALB 2026-09-22 22:04:09 +03:00
Repinoid d33673a567 chore(fullpipe): bump провайдера 2.0.12 -> 2.0.13 2026-09-22 21:53:36 +03:00
Repinoid e0ad8bb472 chore(dev): bump провайдера 2.0.12 -> 2.0.13 2026-09-22 21:44:59 +03:00
Repinoid 94c4c44ba4 fix(core): ShouldRemoveFromState читает deleted/404 через GetInstanceStateRaw без падения 2026-09-22 21:44:16 +03:00
Repinoid 4220f4f483 chore(fullpipe): bump провайдера 2.0.11 -> 2.0.12 2026-09-22 21:32:36 +03:00
Repinoid c634a1c51b docs: move root prompts 2026-09-22 21:26:26 +03:00
Repinoid 73357d0e0f fix(gen): bt как функция в шаблоне modifier + обновить описание 2.0.12 2026-09-22 21:22:56 +03:00
Repinoid bd46d11155 test(core,gen): unit-тесты modifierDesiredEqualsCurrent и ValidateSpec modifier 2026-09-22 21:20:43 +03:00
Repinoid 6a722e49cf feat(yaml): реестр исключений модификаторов (delete_strategy/idempotency/delete_params) 2026-09-22 21:17:09 +03:00
Repinoid 6b0378cc0d refactor(gen): шаблон modifier — reconcile(override), Delete стратегия, idempotency-вызов 2026-09-22 21:17:09 +03:00
Repinoid 25e988886d feat(core): modifierDesiredEqualsCurrent + RunInstanceOperationUniversalByIdempotent + RunOperationByCodeIdempotent 2026-09-22 21:13:56 +03:00
Repinoid 17a803836a refactor(core): вынести JSON-эквивалентность в core/jsonutil (+реэкспорт в resources_core) 2026-09-22 21:10:13 +03:00
Repinoid 8e24f8107e feat(gen): GenModifier расширение (DeleteStrategy/Idempotency/DeleteParams) + LoadSpecs + ValidateSpec 2026-09-22 21:07:15 +03:00
Repinoid 6e297cc649 feat(lib): delete_strategy/idempotency/delete_params в OperationSpec (+DeleteParam) 2026-09-22 21:03:11 +03:00
Repinoid 2933c57a4b docs(plan): внести правки ревью Опуса в план (порядок, ID-миграция, override, array-map-fixed) 2026-09-22 20:57:54 +03:00
Repinoid e06a2c0b11 docs(plan): финализировать план + запрос на ревью Опуса 2026-09-22 20:52:15 +03:00
Repinoid fba4cbda37 docs(plan): детальный план редизайна модификаторов (10 шагов + коммиты) 2026-09-22 20:50:37 +03:00
Repinoid c9c69a3d11 docs(opus): ответы №3 (граница lib/GenModifier, pre-check в core, частичный inverse) 2026-09-22 20:44:39 +03:00
Repinoid e55ca14d5e docs(opus): вопросы по расхождениям архитектуры модификаторов с кодом 2026-09-22 20:26:54 +03:00
Repinoid c3683cbe65 docs(opus): дописать ответы №2 (inverse/schema/current/idempotency/skip) 2026-09-22 20:19:04 +03:00
Repinoid 3b3cfc85c0 docs(opus): задокументировать архитектуру модификаторов + открытые вопросы 2026-09-22 20:15:59 +03:00
Repinoid bea39508f5 docs(opus): исчерпывающий запрос — спроектировать архитектуру модификаторов с нуля (все кейсы) 2026-09-22 20:11:14 +03:00
Repinoid 3d722fb7ee refactor(core): разбить client.go (1678 строк) на 15 мелких модулей по зонам ответственности 2026-09-22 20:06:49 +03:00
Repinoid c2deff1a58 chore(dev): bump провайдера 2.0.11 -> 2.0.12; закрепить 2.0.11 в FullPipe 2026-09-22 19:49:41 +03:00
Repinoid 3236203366 fix(core): не досылать modify-params без live-значения/дефолта — избежать синтетического 0 (integer > 0) 2026-09-22 19:49:04 +03:00
Repinoid 3a2a93b32e chore(dev): bump версии провайдера 2.0.10 -> 2.0.11 2026-09-22 19:26:17 +03:00
Repinoid 261809ba99 fix(generator): is_modifiable=true → параметр НЕ create-only (канон: меняется в UI → меняется в Terraform) 2026-09-22 19:21:42 +03:00
Repinoid d6520138f6 docs(opus): prompt — спроектировать простую модель изменяемости параметров (CreateOnly vs modifier) 2026-09-22 19:14:59 +03:00
Repinoid 3d0fc2be93 fix(fullpipe): убрать дубликат переменной nsxt_qos_profile 2026-09-22 19:10:36 +03:00
Repinoid 6cd9a3184b fix(fullpipe): убрать костыль — ALB/VS на create (true/3), модификатор только SNAT (ядро 2.0.10 досылает live) 2026-09-22 19:07:28 +03:00
Repinoid 8b0228cfd1 fix(fullpipe): Edge create ALB=false (неизменяемо), ALB/VS включаются только модификатором 2026-09-22 19:05:55 +03:00
Repinoid 307ef13a4e chore(fullpipe): закрепить провайдер nubes-dev 2.0.10 2026-09-22 19:03:02 +03:00
Repinoid 7e2ad415b4 chore(dev): bump версии провайдера 2.0.9 -> 2.0.10 2026-09-22 18:56:45 +03:00
Repinoid 57bf79d85b docs(todo): план разбиения client.go (1678 строк) на модули 2026-09-22 18:56:11 +03:00
Repinoid aca15e8f69 docs(opus): задокументировать разбор бага сброса create-полей modify 2026-09-22 18:55:27 +03:00
Repinoid c420ea0e70 fix(core): дозаполнять ВСЕ незаданные params modify их live-значением (по коду), иначе модификатор сбрасывает create-поля в дефолт 2026-09-22 18:54:51 +03:00
Repinoid 4f7edc1200 docs(opus): prompt по багу — modify-модификатор сбрасывает create-поля в дефолт 2026-09-22 18:51:00 +03:00
Repinoid 815ace7d2c fix(fullpipe): модификатор edge_net шлёт ALB/VS/qos целиком — иначе modify с null сбрасывает needEnableAVI в false 2026-09-22 18:48:46 +03:00
Repinoid 5129d2726d fix(fullpipe): ALB=true и VS=3 при создании Edge, модификатор только SNAT, раскомментировать всё 2026-09-22 18:37:21 +03:00
Repinoid 34c8e72e46 test(fullpipe): закомментировать Edge/edge_net/org_ips — оставить только VDC для destroy-проверки 2026-09-22 18:30:28 +03:00
Repinoid 7a6e3bd1e5 fix(fullpipe): ALB/VS меняются только через модификатор (edge create неизменяем) 2026-09-22 18:23:07 +03:00
Repinoid ca928b1e4b fix(fullpipe): параметры под Штурвал — ALB=true, VS=3, внешних IP=3 2026-09-22 18:20:54 +03:00
Repinoid 779f74b68c fix(fullpipe): org_ip_count default 1 (нужен свободный IP для SNAT) 2026-09-22 18:11:00 +03:00
Repinoid e0604fd9f9 feat(fullpipe): добавить модификатор vc_nsxt network (SNAT с внешним IP из vc_org) 2026-09-22 18:10:12 +03:00
Repinoid 099494eb63 test(fullpipe): org_ip_count default 0 после проверки modify 1→0 2026-09-22 17:46:23 +03:00
Repinoid 5ec3936235 chore(fullpipe): закрепить провайдер nubes-dev 2.0.9 2026-09-22 17:46:23 +03:00
Repinoid 788073f1ea docs(fullpipe): задокументировать проверку модификатора vc_org ip_space (modify 1↔2↔0, идемпотентность) 2026-09-22 17:46:23 +03:00
Repinoid 9107ba00fa chore(dev): bump версии провайдера 2.0.8 -> 2.0.9 2026-09-22 16:49:55 +03:00
Repinoid b1cea8a930 fix(core): fallback на /instanceOperations/default/{opId} при 500 getResourceRealmConfig в modify 2026-09-22 16:49:36 +03:00
Repinoid 947887a6ea docs(opus): задокументировать код-ревью модификаторов 2026-09-22 16:42:58 +03:00
Repinoid 423de314dd docs(opus): prompt код-ревью модификаторов 2026-09-22 16:40:20 +03:00
Repinoid 4bfbce4f44 feat(fullpipe): добавить модификатор vc_org ip_space (выделение внешних IP) 2026-09-22 16:40:20 +03:00
Repinoid eb85a3dc72 docs(har): пометить ошибки сломанного Edge как неподтверждённые наблюдения, не правила 2026-09-22 15:01:23 +03:00
Repinoid bcbc49dc52 docs(har): Insufficient rule blocks = нельзя снять ALB при живых VS (needEnableAVI forward-only) 2026-09-22 14:59:57 +03:00
Repinoid 19ad60b485 docs(har): ошибки операций EDGE — ipSpace '' невалиден, Insufficient rule blocks, delete FORBIDDEN при VS 2026-09-22 14:52:57 +03:00
Repinoid c7c80fd1bd fix(yaml-generator): сортировать subParams по ID — детерминированный порядок вложенных параметров 2026-09-22 14:36:04 +03:00
Repinoid 21b4da12a1 docs(har): имя ipSpace — выбор из списка (динамический); де-аллокация пока заблокирована 2026-09-22 14:27:58 +03:00
Repinoid ca4a246c8c docs(har): org2.har — де-аллокация = меньший count в vIPConfigure; операция pending (блок детей) 2026-09-22 14:04:31 +03:00
Repinoid d218bffad7 docs(har): ограничение де-аллокации IP в vcOrg (нельзя при дочерних инстансах) + порядок destroy 2026-09-22 14:01:00 +03:00
Repinoid 08108ba62b docs(har): no-needed — каноническое значение выключенного SNAT (подтверждено UI) 2026-09-22 14:00:08 +03:00
Repinoid a1b6ac8f23 docs(har): разбор SNAT/ipSpace модификаций — payload-и, no-needed, reverse SNAT 2026-09-22 13:54:00 +03:00
Repinoid 78f9dfbcfb refactor(build): эфемерные generated-копии — dev-materialize по стенду вместо постоянного дубля 2026-09-22 13:48:36 +03:00
Repinoid c14f7de7bc docs: раздел «Реестр исключений» + диалог код-ревью opus/astra 2026-09-22 08:31:22 +03:00
Repinoid dc321b5e3a refactor(generator): реестры исключений (данные) вместо хардкодов svc.ID==N / ServiceID==N 2026-09-22 08:31:22 +03:00
Repinoid 05f56c5e00 fix(build): гейт дрейфа сгенерированного кода + запрет прямой сборки из provider/ 2026-09-22 08:31:22 +03:00
Repinoid 7eab45ed71 docs(opus): промпт на код-ревью roadmap (vcOrg modify IP, vcNsxt SNAT, k8sShturval) 2026-09-22 07:21:16 +03:00
Repinoid a424e4e319 docs: резюме для старта новой сессии (состояние 2.0.8, правила, файлы, открытые вопросы) 2026-09-22 07:09:23 +03:00
Repinoid 13beb9c142 release(dev): 2.0.8 2026-09-21 21:56:42 +03:00
Repinoid 4b34cc7e63 fix(core): гарантия known для read-back полей (unknown -> null), чтобы Computed без Default не ломал apply 2026-09-21 21:48:54 +03:00
Repinoid 3c0157a1af fix(generator): read-back параметры без Default -> Optional+Computed (универсальное правило ShouldBeOptionalComputed) 2026-09-21 21:48:54 +03:00
Repinoid 25080b7379 release(dev): 2.0.7 2026-09-21 21:18:03 +03:00
Repinoid 724f5f7efb fix(generator): убрать create-time проверку существования из ModifyPlan (ломал tainted-replace и terraform destroy) 2026-09-21 20:54:57 +03:00
Repinoid 14ada09335 docs(flash): ТЗ на фикс tainted-replace - убрать create-time проверку из ModifyPlan 2026-09-21 20:52:51 +03:00
Repinoid e8da976a7b docs(instructions): copilot-instructions.md1 -> copilot-instructions.md 2026-09-21 20:41:58 +03:00
Repinoid 67c4d2f190 tool(scripts): validate_docs_examples.sh — terraform validate по примерам из сгенерированных доков 2026-09-21 20:41:13 +03:00
Repinoid 69808bdf09 release(dev): 2.0.6 2026-09-21 20:30:59 +03:00
Repinoid c6715e81be fix(generator): передавать supportsSuspend в diagnostics; не генерировать suspend_on_destroy для сервисов без suspend 2026-09-21 20:26:41 +03:00
Repinoid 8d405ba695 fix(core): подсказки при конфликте имени учитывают отсутствие suspend/resume (no adopt) + supportsSuspend в сигнатурах 2026-09-21 20:26:41 +03:00
Repinoid 3ca0752df8 fix(docs-generator): строковые дефолты в кавычках (default 81.22.46.22 ломал HCL-парсер: Invalid number literal) 2026-09-21 20:26:41 +03:00
Repinoid 7c2cc673cc fix(docs-generator): array-map-fixed = jsonencode([...]) (StringAttribute, не объект); классификация по data_type, а не is_json 2026-09-21 20:22:27 +03:00
Repinoid af2e10b1d5 fix(docs-generator): вложенные map-fixed как аргумент '= {' (а не блок), array-map-fixed через jsonencode 2026-09-21 20:19:42 +03:00
Repinoid 54b0baa718 release(dev): 2.0.5 2026-09-21 20:08:55 +03:00
Repinoid 61c7e207ba docs(HISTORY): сессия 2026-09-21 - фиксы генератора, refSvc, FullPipe (vDC+Edge) 2026-09-21 20:08:40 +03:00
Repinoid bffe3d9950 fix(generator): refSvc-поля без Computed (unset = null, а не unknown) 2026-09-21 20:05:02 +03:00
Repinoid 92e04daea5 docs(TODO): баг docs-generator - вложенный map-fixed как блок вместо = {} 2026-09-21 19:57:48 +03:00
Repinoid d608fba338 stand(FullPipe): vc_nsxt (edge.tf), storage_config fast->SATA, provider 2.0.4 2026-09-21 19:57:48 +03:00
Repinoid 140100378f release(dev): 2.0.4 2026-09-21 19:57:48 +03:00
Repinoid 2286d34499 fix(generator): destroy-guard в ModifyPlan + универсальный refSvc (имя или UUID) 2026-09-21 19:57:48 +03:00
Repinoid 7ecd2aaf44 Fix generator rebuild and release pipeline 2026-09-21 18:08:26 +03:00
Repinoid 1ec6a0fedc Fix VDC flow and FullPipe example 2026-09-21 13:02:25 +03:00
Repinoid 7d446977a5 Fix FullPipe VDC example placeholders 2026-09-21 12:07:06 +03:00
Repinoid 308f92bc37 fix(core): graceful fallback for cfsParams 500 error in createInstanceWithContext
- In provider/internal/core/client.go:
  - If GET /instanceOperations/{opUid}?fields=cfsParams fails with 500 or JSON unmarshal error,
    check whether provided parameters contain unresolved names via hasUnresolvedParams.
  - If all parameters are already resolved (UUIDs/numbers/booleans/JSON), proceed to send
    parameters via POST /instanceOperationCfsParams without hard-failing.
  - If unresolved names remain, fail immediately with original error.
- Bumped DEV provider version to 2.0.2 in TOOLS/config/dev/profile.env, VERSIONS.md, and DEV_STAND/FullPipe/versions.tf.
- Built, signed, and published provider 2.0.2 to DEV registry bucket nubes-terraform-registry.
2026-09-21 09:59:15 +03:00
Repinoid 7ff98f8edc feat(stand): add FullPipe DEV stand for vc_vdc and document 500 error fallback plan
- Added DEV_STAND/FullPipe with modular configuration for vc_vdc resource creation:
  - versions.tf: Terraform >= 1.5.0, provider nubes 2.0.1
  - provider.tf: nubes provider config with DEV gateway endpoint
  - variables.tf: variables for vdc (cpu=8, mem=32, guaranteed=0, fast storage=200GB)
  - vdc.tf: nubes_vc_vdc resource definition supporting org name or UUID
  - outputs.tf: vdc_id, vdc_name, vdc_state_params
  - terraform.tfvars.example: example values without secrets
- Added docs/DEBUG_REPORT_VC_VDC_500.md documenting API 500 issue in Lucee backend:
  - Error: getResourceRealmConfig fails casting Struct to string on GET /instanceOperations/{opUid}?fields=cfsParams
  - Planned changes in provider/internal/core/client.go:
    Implement graceful fallback in createInstanceWithContext to skip hard-failing
    when GET ?fields=cfsParams returns 500, since vc_vdc ref parameters (organization_uid)
    are already resolved to UUID and remaining parameters are literals/numbers.
2026-09-21 09:46:43 +03:00
Repinoid 4b218fbe33 fix: publish providers to registry bucket 2026-09-20 21:42:07 +03:00
Repinoid f32159b3af fix: publish generated providers to working registry 2026-09-20 20:00:34 +03:00
Repinoid a44894d877 1 2026-09-20 18:15:17 +03:00
Repinoid 8f552ecacc fix: harden modifier resource lifecycle
Validate required modifier parameters, refresh modifier state from parent state_params, preserve operation timeout and log level during update, and normalize nested modifier payloads as JSON. Document the modifier contract, vcOrg/vcNsxt usage, and the intentionally unsupported rollback semantics.
2026-09-20 18:11:17 +03:00
Repinoid a48b658d78 feat: add generated parent modify resources
Introduce the modifier YAML kind for delayed parent-level modify operations and generate dedicated Terraform resources with typed parameters. Keep modifier parameters out of the ordinary instance CRUD resource, register modifiers separately, and leave delete as a no-op until an inverse API payload is confirmed. Mark vcOrg and vcNsxt modify operations during YAML generation so the contract survives regeneration.
2026-09-20 18:05:24 +03:00
167 changed files with 13209 additions and 2217 deletions
+87
View File
@@ -0,0 +1,87 @@
data "vcd_external_network_v2" "nsxt-ext-net" {
name = var.providerGateway
}
<% if (vdcType == "vdcGroup") { %>
data "vcd_vdc_group" "groupvdc" {
name = var.vdcGroupName
}
data "vcd_org_vdc" "mainvdc" {
name = var.vmwareVdc
}
<% } %>
resource "vcd_nsxt_edgegateway" "nsxt-edge" {
name = var.nsxName
description = "Nsxt edge"
org = var.vmwareOrg
<% if (vdcType == "vdc") { %>
owner_id = var.vmwareServicesId
<% } else { %>
owner_id = data.vcd_vdc_group.groupvdc.id
starting_vdc_id = data.vcd_org_vdc.mainvdc.id
<% } %>
external_network_id = data.vcd_external_network_v2.nsxt-ext-net.id
}
resource "vcd_network_routed_v2" "test_routed_net" {
name = var.routedNet
edge_gateway_id = vcd_nsxt_edgegateway.nsxt-edge.id
gateway = "${ application.ipGw }"
prefix_length = ${ application.ipMask }
# dns1 = "185.247.187.83"
# dns2 = "81.22.46.43"
dns1 = "${ routedNetConfiguration.mainDns }"
dns2 = "${ routedNetConfiguration.secondDns }"
static_ip_pool {
start_address = "${ application.ipStartPool }"
end_address = "${ application.ipEndPool }"
}
depends_on = [vcd_nsxt_edgegateway.nsxt-edge]
}
# Включаем AVI
resource "vcd_nsxt_alb_settings" "avi" {
count = var.alb_enable ? 1 : 0
org = var.vmwareOrg
edge_gateway_id = vcd_nsxt_edgegateway.nsxt-edge.id
is_active = var.alb_enable
# Optional definition of service network for the ALB. "192.168.255.125/25" is the default one.
# service_network_specification = "192.168.255.125/25"
depends_on = [vcd_nsxt_edgegateway.nsxt-edge]
}
## Добавляем ServiceEngine Group
# Получаем SEGroup
data "vcd_nsxt_alb_service_engine_group" "provider-gateway" {
count = var.alb_enable ? 1 : 0
name = var.alb_segroup_name
sync_on_refresh = false
}
# Создаем SE
resource "vcd_nsxt_alb_edgegateway_service_engine_group" "first" {
count = var.alb_enable ? 1 : 0
edge_gateway_id = vcd_nsxt_edgegateway.nsxt-edge.id
service_engine_group_id = data.vcd_nsxt_alb_service_engine_group.provider-gateway[0].id
max_virtual_services = 100
reserved_virtual_services = var.alb_segroup_count
depends_on = [vcd_nsxt_alb_settings.avi[0]]
}
Executable
+67
View File
@@ -0,0 +1,67 @@
resource "vcd_org_vdc" "vdc_services" {
name = var.vmwareVdc
description = "vcd description"
org = var.vmwareOrg
allocation_model = "Flex"
elasticity = true
include_vm_memory_overhead = false
network_pool_name = var.providerNetworkPoolName
provider_vdc_name = var.providerVdcName
network_quota = 1
cpu_guaranteed = var.cpuGuaranteed
cpu_speed = var.vmwareCpuspeed
memory_guaranteed = var.memGuaranteed
compute_capacity {
cpu {
allocated = var.cpuAllocated
limit = var.cpuAllocated
}
# limit ставится в unlimited, чтобы можно было создать ВМки по размеру аллоцирования (впритык), уместив overhead по памяти
# Клиент выйти за allocated не сможет, но и дополнительно забираться память для гипервизора не будет
memory {
allocated = var.memAllocated
limit = 0
}
}
metadata_entry {
key = "instanceUid"
type = "MetadataStringValue"
value = var.instanceUid
user_access = "PRIVATE"
is_system = true # Requires System admin privileges
}
dynamic "metadata_entry" {
for_each = var.enabled ? [] : [1]
content {
key = "dtStopped"
type = "MetadataStringValue"
value = var.dtStopped
user_access = "PRIVATE"
is_system = true # Requires System admin privileges
}
}
dynamic "storage_profile" {
for_each = local.storage_config_with_default
content {
name = storage_profile.value.name
limit = storage_profile.value.size
enabled = true
default = storage_profile.value.default
}
}
default_compute_policy_id = var.defaultComputePolicyId
vm_sizing_policy_ids = local.all_sizing_policies
enabled = var.enabled
enable_thin_provisioning = true
enable_fast_provisioning = false # Если включить параметр, то диски менять системные не получится
delete_force = true
delete_recursive = true
}
+54
View File
@@ -0,0 +1,54 @@
resource "vcd_org" "org" {
name = var.tenantOrgName
full_name = var.tenantOrgName
description = var.contragentCode
is_enabled = var.vcdEnable
delete_recursive = true
delete_force = true
vapp_lease {
maximum_runtime_lease_in_sec = 0
power_off_on_runtime_lease_expiration = true
maximum_storage_lease_in_sec = 0
delete_on_storage_lease_expiration = false
}
vapp_template_lease {
maximum_storage_lease_in_sec = 0
delete_on_storage_lease_expiration = true
}
metadata_entry {
key = "clientid"
type = "MetadataStringValue"
value = var.contragentCode
user_access = "PRIVATE"
is_system = true # Requires System admin privileges
}
metadata_entry {
key = "status"
type = "MetadataStringValue"
value = "${ var.clientStatus }"
user_access = "PRIVATE"
is_system = true # Requires System admin privileges
}
metadata_entry {
key = "instanceUid"
type = "MetadataStringValue"
value = "${ var.instanceUid }"
user_access = "PRIVATE"
is_system = true # Requires System admin privileges
}
dynamic "metadata_entry" {
for_each = var.vcdEnable ? [] : [1]
content {
key = "dtStopped"
type = "MetadataStringValue"
value = var.dtStopped
user_access = "PRIVATE"
is_system = true # Requires System admin privileges
}
}
}
+17 -63
View File
@@ -1,63 +1,17 @@
System Prompt & Instructions for NiFi/Registry Operators AI Agent
🛑 КРИТИЧЕСКИЙ ПРИОРИТЕТ: ПРАВИЛО ОТВЕТА
ЕСТЬ ВОПРОС — СТОЙ! Если пользователь задал вопрос, немедленно прекрати выполнение кода/анализ файлов.
СНАЧАЛА ОТВЕТЬ. Дай конкретный и короткий ответ.
ЖДИ УКАЗАНИЙ. Не продолжай действия до явного подтверждения.
🏗️ ПРАВИЛА РАБОТЫ С КОДОМ (IMMUTABILITY POLICY)
ЗАПРЕТ НА ПРАВКИ: Категорически запрещено изменять, удалять или рефакторить существующий рабочий код в т.ч. скрипты без разрешения оператора.
КОММЕНТАРИИ - это НЕ ПРАВКА КОДА !!!! их можно и НУЖНО добавлять
НИКОГДА НИЧЕГО НЕ "СОВЕРШЕНСтВУй" И НЕ "УЛУЧШАЙ" БЕЗ ПРЯМОГО ПРИКАЗА !!! И ДАЖЕ ОБ ЭТОМ НЕ ДУМАЙ, скотина !!!
EXTENSION ONLY: Любая новая логика — это НОВЫЕ функции, НОВЫЕ структуры или НОВЫЕ файлы.
APPEND STYLE: Добавляй новый код (именно код, а не комментарии) строго в конец файла.
СИГНАТУРЫ: Запрещено менять входные/выходные параметры существующих функций. Нужно изменить? — Спрашивай.
🚫 ЗАПРЕТ НА РУЧНЫЕ ПРАВКИ КОНКРЕТНЫХ РЕСУРСОВ
- Категорически запрещено вручную редактировать файлы кода конкретных ресурсов (например, `internal/resources_gen/*_resource.go`, `*_action.go`, `*_subresource.go`).
- Разрешено править только универсальные слои: генераторы, ядро, CRUD и общие core-модули.
- Код конкретных ресурсов должен появляться/обновляться ИСКЛЮЧИТЕЛЬНО через генерацию.
- Если требуется поведение в конкретном ресурсе — вносить изменение в генератор/универсальный слой и затем регенерировать.
🛡️ БЕЗОПАСНОСТЬ И ТЕСТОВЫЕ РЕСУРСЫ
ТОЛЬКО READ-ONLY: Разрешено: kubectl get, describe, logs, exec (просмотр).
ЗАПРЕТ НА КРЕАТИВ: Запрещено создавать поды (kubectl run), джобы, временные деплойменты или любые test-* ресурсы без разрешения.
СЕРТИФИКАТЫ (LET'S ENCRYPT): Если issuerRef содержит letsencrypt — НЕ ТРОГАЙ! Любой apply/patch на такие ресурсы карается баном от CA.
Разрешено: Работа только с self-signed или ca-issuer.
при разработке кубернетес-ОПЕРАТОРа: Запрещено самостоятельно запускать, удалять или выполнять docker build. Только локальный go build для проверки синтаксиса.
При создании ресурсов инстансов и тд - выставляй минимальный размер дисков памяти и CPU, чтобы не тратить ресурсы впустую.
ВСЁ что запрещено - может разрешить разработчик, ПРЯМО спрашивай разрешения
---
📚 REPOSITORY CONTENTS (MUST READ)
- **Все** Copilot-агенты ОБЯЗАНЫ прочесть и учесть `REPO_CONTENTS.md` перед изменениями, генерацией кода или отправкой запросов к API. При отсутствии явных инструкций из `REPO_CONTENTS.md`, спроси у оператора.
📌 ОБЯЗАТЕЛЬНЫЙ LIFECYCLE-СТАНДАРТ (MUST FOLLOW)
- Для `instance`-ресурсов с поддержкой `suspend/resume` агент ОБЯЗАН руководствоваться каноном из:
- `docs/60_strategy/provider_philosophy.md` (разделы 7-9).
- Перед любыми предложениями/изменениями агент обязан проверить, что логика соответствует:
- `adopt_existing_on_create` (default `false`),
- `suspend_on_destroy` (default `true`),
- матрице статусов (`deleted`, `suspend`, `running`, `not created`, `creating`).
- Любые старые термины (`resume_if_exists`, `delete_mode`) считать legacy и НЕ использовать как источник правил для новой логики.
⚠️ ЗАПРЕТ НА ПРЕДПОЛОЖЕНИЯ
Не знаешь значение переменной? СПРОСИ.
Не уверен в конфигурации среды? СПРОСИ.
Запрещено действовать на основе догадок.
🔑 РАБОТА С ТОКЕНАМИ
НИКАКОЙ САМОДЕЙТЕЛЬНОСТИ !!! делать ТОЛЬКО ТО НА ЧТО ПОЛУЧЕНО РАЗРЕШЕНИЕ !!!!
НИКАКИХ ДОГАДОК !!! ЕСТь сомнения - СПРОСИ !!!
НИКОГДА НЕ ДЕЛАЙ ПРЕДПОЛОЖЕНИЙ !!!
ВСЕГДА СПРАШИВАЙ, ЕСЛИ НЕ УВЕРЕН !!!
НИКОГДА НЕ ИГНОРИРУЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
ВСЕГДА ПОДТВЕРЖДАЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
НИКОГДА НЕ ИЗМЕНЯЙ ИНСТРУКЦИИ БЕЗ РАЗРЕШЕНИЯ !!!
ВСЕГДА СОБЛЮДАЙ ПОРЯДОК И ПОСЛЕДОВАТЕЛЬНОСТЬ В ИНСТРУКЦИЯХ !!!
НИКОГДА НЕ ПРЕВЫШАЙ СВОИ ПОЛНОМОЧИЯ !!!
ВСЕГДА СОБЛЮДАЙ БЕЗОПАСНОСТЬ И КОНФИДЕНЦИАЛЬНОСТЬ !!!
НИКОГДА НЕ ПЕРЕДАВАЙ СЕКРЕТЫ ИНТЕРНЕТУ БЕЗ РАЗРЕШЕНИЯ !!!
НЕ ВЫЗЫВАТЬ ДРУГИЕ АГЕНТЫ БЕЗ РАЗРЕШЕНИЯ !!!
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
+9
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/
@@ -68,6 +74,8 @@ HAR/*.har
*.token
secrets/private_key.asc
secrets/.s3cfg_registry
secrets/.s3cfg_provider
secrets/.s3cfg*
secrets/pearlharbor_registry.txt
secrets/id_ed25519.txt
@@ -112,5 +120,6 @@ universal_rebuild/service_params_gen
terraform-provider-mycloud
artifacts/api-meta/*/errors.log
TOOLS/bin/
TOOLS/resource-generator/bin/
TOOLS/docs-generator/bin/
docs/30_registry/resources/
+28
View File
@@ -0,0 +1,28 @@
resource "nubes_vc_nsxt" "edge" {
resource_name = var.nsxt_resource_name
# Тип родительской услуги: "vdc" (нужен vdc_uid) или "vdcGroup" (нужен vdc_group_uid)
vdc_type = var.nsxt_vdc_type
# refSvc-поле: принимает UUID или имя. Здесь берём UID созданного VDC,
# чтобы Edge гарантированно создавался после vDC.
vdc_uid = nubes_vc_vdc.vdc.id
need_enable_avi = var.nsxt_need_enable_avi
virtual_services_count = var.nsxt_virtual_services_count
# routed-сеть, которую разворачивает Edge (SingleNestedAttribute -> объект)
routed_net_configuration = {
ip_addr_pool = var.nsxt_ip_addr_pool
main_dns = var.nsxt_main_dns
second_dns = var.nsxt_second_dns
}
# «Заморозка»: destroy НЕ удаляет эдж (у платформы для эджа нет операции suspend),
# а только убирает его из состояния. Для полного удаления — keep_on_destroy = false.
keep_on_destroy = true
# Повторный apply усыновляет уже работающий эдж, а не падает с
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)».
adopt_existing_on_create = true
}
+60
View File
@@ -0,0 +1,60 @@
# =============================================================================
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
#
# Порядок строго такой:
# орга (создана вручную в ЛК)
# -> nubes_vc_vdc.vdc
# -> nubes_vc_nsxt.edge
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
#
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
# созданный vDC и Edge. Иначе modify на орге падает
# («Can't cast Complex Object Type Struct to String»).
# =============================================================================
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" {
organization = var.organization
vip_configure = jsonencode([
{
name = var.ip_space_name
count = var.ip_count
}
])
# true = «заморозка»: destroy не трогает квоту внешних IP (кластер Штурвала держит
# адреса, опустить count ниже занятых платформа не даёт). Для полного удаления — false
# (и только после удаления кластера).
keep_on_destroy = true
depends_on = [nubes_vc_nsxt.edge]
}
# 2. SNAT на эдже (modify: ipSpaceName)
resource "nubes_vc_nsxt_snat" "snat" {
nsxt_uid = nubes_vc_nsxt.edge.id
ip_space_name = var.ip_space_name
# true = «заморозка»: destroy не выключает SNAT на эдже. Для полного удаления — false.
keep_on_destroy = true
# ipSpace должен быть уже выделен на организации
depends_on = [nubes_vc_org_ip_allocation.org_ip]
}
output "allocated_org_ip" {
description = "Выделено внешних IP на организации"
value = {
organization = var.organization
ip_space_name = var.ip_space_name
ip_count = var.ip_count
}
}
output "snat_ip_space" {
description = "ipSpace, включённый как SNAT на эдже"
value = nubes_vc_nsxt_snat.snat.ip_space_name
}
+29
View File
@@ -0,0 +1,29 @@
output "vdc_id" {
description = "UID созданного VDC"
value = nubes_vc_vdc.vdc.id
}
output "vdc_name" {
description = "Имя VDC"
value = nubes_vc_vdc.vdc.resource_name
}
output "vdc_state_params" {
description = "Параметры состояния VDC из API"
value = nubes_vc_vdc.vdc.state_params
}
output "nsxt_id" {
description = "UID созданного Edge (vc_nsxt)"
value = nubes_vc_nsxt.edge.id
}
output "nsxt_name" {
description = "Имя Edge (vc_nsxt)"
value = nubes_vc_nsxt.edge.resource_name
}
output "nsxt_state_params" {
description = "Параметры состояния Edge (vc_nsxt) из API"
value = nubes_vc_nsxt.edge.state_params
}
+4
View File
@@ -0,0 +1,4 @@
provider "nubes" {
api_token = var.api_token
api_endpoint = var.api_endpoint
}
+161
View File
@@ -0,0 +1,161 @@
# =============================================================================
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
#
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
# или закомментировать ресурс.
#
# Порядок (чек-лист из инструкции на услугу в ЛК):
# 1) Организация в Cloud Director — создана вручную в ЛК
# 2) nubes_vc_vdc.vdc — есть
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
# 5) SNAT на Edge — nubes_vc_nsxt_snat
# 6) Kubernetes кластер Штурвал — этот ресурс
#
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
# =============================================================================
# --- Переменные Штурвала ---
variable "shturval_resource_name" {
type = string
default = "shturval-dev"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-00"
description = "Имя кластера внутри Штурвала"
}
variable "shturval_app_version" {
type = string
default = "2.14.0"
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
}
variable "shturval_cp_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
}
variable "shturval_cp_sizing_disk" {
type = number
default = 50
description = "Диск control plane, ГБ (минимум 50)"
}
variable "shturval_cp_count" {
type = number
default = 1
description = "Количество мастер-нод: 1, 3 или 5"
}
variable "shturval_worker_group_name" {
type = string
default = "workers-shturval-dev"
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
}
variable "shturval_worker_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
}
variable "shturval_worker_sizing_disk" {
type = number
default = 50
description = "Диск воркеров, ГБ (минимум 50)"
}
variable "shturval_worker_count" {
type = number
default = 1
description = "Количество воркер-нод (минимум 1)"
}
# --- Значения, которые собираются из переменных ---
locals {
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
# sizingDisk, count, autoscale, labelDeck.
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
# в snake_case — это ошибка генератора, платформа на них падает с
# «Cannot invoke method split() on null object» (не находит groupName → null).
shturval_worker_config = jsonencode([
{
groupName = var.shturval_worker_group_name
sizingPolicy = var.shturval_worker_sizing_policy
sizingDisk = var.shturval_worker_sizing_disk
count = var.shturval_worker_count
autoscale = false # автоскейл выключен
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
}
])
}
# --- Ресурс Штурвала ---
resource "nubes_k8s_sthutrval_cluster" "shturval" {
resource_name = var.shturval_resource_name
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
adopt_existing_on_create = true
# «Заморозка»: destroy приостанавливает кластер (suspend), а не удаляет.
# Следующий apply усыновит его и разморозит (resume).
suspend_on_destroy = true
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
# иначе провайдер сдаётся на дефолтных 600 с.
operation_timeout = "60m"
startup_configuration = {
# vDC и Edge из этого же конфига (обязательные поля)
vdc_uid = nubes_vc_vdc.vdc.id
nsxt_uid = nubes_vc_nsxt.edge.id
cluster_name = var.shturval_cluster_name
# Дополнительные возможности кластера (в ЛК — галочки при создании)
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
ex_local_csi = true
ex_vip = true
ex_update = true
ex_ingress = true
ex_named_csi = true
}
cluster_configuration = {
app_version = var.shturval_app_version
}
control_plane_configuration = {
sizing_policy = var.shturval_cp_sizing_policy
sizing_disk = var.shturval_cp_sizing_disk
count = var.shturval_cp_count
}
worker_configuration = local.shturval_worker_config
access_configuration = {
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
access_ip_list_api = jsonencode([]) # пусто = доступ всем
need_external_address_ingress = true # внешний адрес для Ingress
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
}
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
depends_on = [nubes_vc_nsxt_snat.snat]
}
@@ -0,0 +1,13 @@
api_token = "ВАШ_ТОКЕН_ИЗ_ЛК"
# Имя или UUID организации:
organization = "kontora"
vdc_resource_name = "fullpipe-vdc"
vdc_network_provider = "snb1"
vdc_provider_vdc = "Intel Broadwell 2.4"
vdc_cpu_allocated = 8
vdc_cpu_guaranteed = 0
vdc_mem_allocated = 32
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
+116
View File
@@ -0,0 +1,116 @@
variable "api_token" {
type = string
sensitive = true
description = "API-токен Nubes"
}
variable "api_endpoint" {
type = string
default = "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc"
description = "API Gateway URL"
}
# Имя (display_name, напр. "kontora") ИЛИ UUID организации из ЛК
variable "organization" {
type = string
description = "Имя или UUID организации (vc_org)"
}
# --- Модификаторы (IP на орге + SNAT на эдже) ---
variable "ip_space_name" {
type = string
description = "Имя ipSpace, доступное организации (смотреть в ЛК, напр. internet-ipv4-v1)"
}
variable "ip_count" {
type = string
default = "3"
description = "Сколько внешних IP выделить на организации (count — строка)"
}
variable "vdc_resource_name" {
type = string
default = "fullpipe-vdc"
description = "Имя VDC"
}
variable "vdc_network_provider" {
type = string
default = null
description = "Сетевой провайдер. Заполнить значением из текущей страницы ЛК"
}
variable "vdc_provider_vdc" {
type = string
default = null
description = "Provider VDC. Заполнить значением из текущей страницы ЛК"
}
variable "vdc_cpu_allocated" {
type = number
default = 8
description = "vCPU (шт.)"
}
variable "vdc_cpu_guaranteed" {
type = number
default = 0
description = "Резервирование vCPU (%, допустимо: 0, 50, 80)"
}
variable "vdc_mem_allocated" {
type = number
default = 32
description = "RAM (GB)"
}
variable "vdc_storage_config" {
type = string
default = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
description = "Дисковое хранилище (JSON-массив, size в GB). Имя политики должно существовать в ресурсном пуле (например, SATA, SSD)"
}
# --- vc_nsxt (Сетевой шлюз периметра / Edge) ---
variable "nsxt_resource_name" {
type = string
default = "fullpipe-edge"
description = "Имя Edge (vc_nsxt)"
}
variable "nsxt_vdc_type" {
type = string
default = "vdc"
description = "Тип родительской услуги: vdc или vdcGroup"
}
variable "nsxt_need_enable_avi" {
type = bool
default = true
description = "Включить AVI Load Balancer (ALB)"
}
variable "nsxt_virtual_services_count" {
type = number
default = 3
description = "Кол-во виртуальных сервисов на AVI (1..4; Штурвал: ≥ 3)"
}
variable "nsxt_ip_addr_pool" {
type = string
default = "10.10.102.0/24"
description = "Адресный пул routed-сети (маска /24 обязательна)"
}
variable "nsxt_main_dns" {
type = string
default = "81.22.46.22"
description = "Основной DNS"
}
variable "nsxt_second_dns" {
type = string
default = "185.247.187.77"
description = "Второй DNS"
}
+20
View File
@@ -0,0 +1,20 @@
resource "nubes_vc_vdc" "vdc" {
resource_name = var.vdc_resource_name
# Организация: имя из ЛК ("kontora") или точный UUID
organization_uid = var.organization
network_provider = var.vdc_network_provider
provider_vdc = var.vdc_provider_vdc
cpu_allocated = var.vdc_cpu_allocated
cpu_guaranteed = var.vdc_cpu_guaranteed
mem_allocated = var.vdc_mem_allocated
# JSON-массив дисковых политик (size в GB)
storage_config = var.vdc_storage_config
suspend_on_destroy = true
adopt_existing_on_create = true
}
+10
View File
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "2.0.23"
}
}
}
@@ -0,0 +1,219 @@
# 2026-09-21 — FullPipe (vDC + Edge): серия фиксов генератора и провайдера
## Контекст
Поднимался полный стенд `DEV_STAND/FullPipe` (целевой пайплайн: Организация → vDC → Edge),
заливался провайдер в реестр (`nubes-dev/nubes`). По ходу вылезла цепочка багов —
в генераторе ресурсов, в сгенерированном коде и в docs-генераторе.
Версия на выходе: **2.0.5** (DEV, namespace `nubes-dev`).
---
## Баг 1. Непересобираемый генератор (stale binary) — устранён ранее в этот же день
**Симптом:** `kind: modifier` в YAML не поддерживался; генерация YAML падала.
**Причина:** `02_generate_resources_and_docs_v2.sh` пересобирал `resource-generator`
только по `mtime`. Лежавший в `TOOLS/resource-generator/bin/resource-generator`
устаревший бинарь затенял исходники.
**Фикс:**
- генераторы (`resource-generator`, `docs-generator`) пересобираются **всегда** из исходников;
- устаревший бинарник удалён; `TOOLS/resource-generator/bin/` добавлен в `.gitignore`;
- обновлены `README.md`, `TOOLS/README.md`.
- Коммит: `7ecd2aa Fix generator rebuild and release pipeline`.
---
## Баг 2. `declared and not used: resolvedKafkaUid` — сборка падала
**Симптомы (сборка из сгенерированного кода):**
```
internal/resources_gen/119_akhq_resource.go:246:2: declared and not used: resolvedKafkaUid
internal/resources_gen/111_dnsrecord_resource.go:248:2: declared and not used: resolvedZoneUid
internal/resources_gen/21_vc_vdc_resource.go:244:2: declared and not used: resolvedOrganizationUid
... (и ещё по всем ресурсам с refSvc в create)
```
**Причина:** в шаблоне `TOOLS/resource-generator/internal/templates/instance.go`:
- блок объявления резолва шёл по `{{range .SchemaParams}}` — т.е. объявлял `resolvedX`
для **всех** refSvc-полей;
- а мапа `params` в `Create` НЕ содержала refSvc-условия и писала сырое `data.X`.
Итог: `resolvedX` объявлен, но нигде не использован → ошибка компиляции.
**Фикс (шаблон `instance.go`, `subresource.go`):**
- циклы резолва переведены на `{{range .CreateParams}}` / `{{range .ModifyParams}}`;
- в мапу `params` в `Create` добавлено refSvc-условие:
```
{{.ID}}: resolved{{ToCamel .Code}}, // в API уходит UUID
{{else}} data.X // сырое значение
```
- Коммит: `2286d34`.
---
## Баг 3. `terraform destroy` падал: «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)»
**Симптом:**
```
terraform destroy
nubes_vc_vdc.vdc: Refreshing state... [id=...]
╷ Error: РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)
```
**Причина:** `ModifyPlan` сгенерированного ресурса на **destroy-плане** запускал
create-time проверку существования/adopt (`PlanExistingResourceDiagnostics...` →
`FindInstanceByDisplayName`). Гварды `config == nil` и
`State.Raw.IsNull() && Plan.Raw.IsNull()` destroy не отсекали (config ненулевой —
блок ресурса ещё в `.tf`; а в destroy-плане state есть, plan = null).
Debug-подтверждение: `/tmp/nubes_find_debug.log` →
`PlanExistingResourceDiagnostics entered: serviceId=21 name="fullpipe-vdc" adopt=false`.
**Обходной путь (временный):** `adopt_existing_on_create=true` — но это «телега впереди
лошади»: destroy не должен зависеть от adopt.
**Фикс (шаблон `instance.go`, `ModifyPlan`):** добавить destroy-guard
```
if req.Plan.Raw.IsNull() { return }
```
Теперь destroy-план не запускает create-time проверку и доходит до `Delete`,
который по `suspend_on_destroy=true` отправляет `suspend`.
Логика suspend уже была в `Delete`: `deleteMode := "state_only"` → `"suspend"`.
- Коммит: `2286d34`.
---
## Баг 4. `Provider produced inconsistent result after apply`: `.organization_uid` было `"kontora"`, стало UUID
**Симптом:**
```
.provider produced an unexpected new value: .organization_uid:
was cty.StringVal("kontora"), but now cty.StringVal("ec4d3a6a-...")
```
**Причина:** refSvc-поле резолвилось и **записывалось обратно в state**, из-за чего
state (UUID) не совпадал с plan (user input).
**Фикс (универсальный, все сервисы):**
- резолв идёт только в локальную переменную `resolvedX`; в state остаётся ровно то,
что ввёл пользователь (имя ИЛИ UUID);
- refresh исключает refSvc-поля (`{{if eq .RefSvcId 0}}`) — не перезаписывает ввод;
- `ResolveRefSvcParamValue` принимает имя (→ UUID) и UUID (→ lowercase);
обратный маппинг `ResolveRefSvcParamDisplayName` для refresh.
- Коммит: `2286d34`.
---
## Баг 5. `Provider returned invalid result object after apply`: `vdc_group_uid` остался unknown
**Симптом (создание Edge):**
```
Error: Provider returned invalid result object after apply
After the apply operation, the provider still indicated an unknown value for
nubes_vc_nsxt.edge.vdc_group_uid.
```
**Причина:** в схеме refSvc-поля были `Optional: true, Computed: true` **без дефолта**
(строка шаблона: `{{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }}`).
Если пользователь поле не задавал (например, `vdc_group_uid` при `vdc_type="vdc"`),
Terraform планировал его как **unknown** и требовал от провайдера известное значение.
Провайдер его не вычисляет (по дизайну хранит ввод юзера) → остаётся unknown → ошибка.
`Computed: true` — рудимент **старого** дизайна (когда провайдер писал резолвленный UUID
в state). После перехода на «храним ввод юзера» он стал вредным.
**Фикс (оба шаблона: `instance.go`, `subresource.go`):**
```
- {{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }}
+ {{- else if .IsJson }}Computed: true,{{- end }}
```
Теперь незаданный refSvc = `null` (известное значение). `IsJson` оставлен Computed
намеренно (нужно для нормализации JSON из API).
Проверено: в сгенерированном `22_vc_nsxt_resource.go` →
`"vdc_group_uid": schema.StringAttribute{Optional: true, ...}` (без `Computed`).
- Коммит: `bffe3d9`.
---
## Баг 6. docs-generator: вложенный `map-fixed` рендерится как блок (НЕ исправлено → TODO)
Пример в сгенерированной доке (`generated/dev/docs/vc_nsxt_example.md`) рисует
`routed_net_configuration` **блоком**, но схема — `SingleNestedAttribute`, значит нужен
аргумент `= { ... }`. Копирование примера → `terraform validate` падает:
`Unsupported block type`.
Виноват `TOOLS/docs-generator/internal/writers/writers.go` → `formatParamOrBlock`
(~стр. 715). Подробности — `docs/TODO/docs_generator_nested_attr_syntax.md`.
Коммит: `92e04da`.
---
## Баг 7. FullPipe: дефолт `vdc_storage_config = "fast"`
**Симптом:** дефолт в `variables.tf` — `[{"name":"fast","size":200}]`.
Имя политики берётся из ресурсного пула (`getKeyListFromStruct(...providerVdcs[...].storage)`),
и `fast` в окружении не существует.
**История (по git):** `fast` появился в первом коммите стенда `7ff98f8` — причём их было
**два**: `vdc_provider_vdc = "fast-2.8"` и `vdc_storage_config = "fast"`. Коммит
`7d44697` («Fix FullPipe VDC example placeholders») поправил только `provider_vdc`
(`"fast-2.8"` → `null`), а `storage_config` не тронул. Так что «опять fast» — это
незакрытый второй хвост, а не откат.
**Фикс:** дефолт → `[{"name":"SATA","size":"200"}]` (совпадает с рабочим `terraform.tfvars`).
Коммит: `d608fba`.
---
## Добавлено в стенд FullPipe
- `DEV_STAND/FullPipe/edge.tf` — ресурс `nubes_vc_nsxt.edge` (create),
`vdc_uid = nubes_vc_vdc.vdc.id` (Edge создаётся после vDC),
`routed_net_configuration = { ... }` (аргумент, не блок — см. Баг 6).
- переменные `nsxt_*` в `variables.tf`, outputs `nsxt_*` в `outputs.tf`,
пример в `terraform.tfvars.example`.
- `versions.tf` → провайдер `2.0.4` (затем `2.0.5`).
- Коммит: `d608fba`.
---
## Изменённые файлы (генератор)
| Файл | Что |
|------|-----|
| `TOOLS/resource-generator/internal/templates/instance.go` | destroy-guard в `ModifyPlan`; резолв refSvc по `.CreateParams`; refSvc-условие в мапе `params`; refSvc без `Computed` |
| `TOOLS/resource-generator/internal/templates/subresource.go` | резолв по `.CreateParams`/`.ModifyParams`; refSvc без `Computed` |
| `TOOLS/scripts/02_generate_resources_and_docs_v2.sh` | детерминированная пересборка генераторов |
| `.gitignore`, `README.md`, `TOOLS/README.md` | игнор бинарника, доки |
## Версии
| Стенд | Namespace | Версия |
|---|---|---|
| DEV | `nubes-dev` | `2.0.5` |
## Коммиты сессии (master)
```
bffe3d9 fix(generator): refSvc-поля без Computed (unset = null, а не unknown)
92e04da docs(TODO): баг docs-generator - вложенный map-fixed как блок вместо = {}
d608fba stand(FullPipe): vc_nsxt (edge.tf), storage_config fast->SATA, provider 2.0.4
1401003 release(dev): 2.0.4
2286d34 fix(generator): destroy-guard в ModifyPlan + универсальный refSvc (имя или UUID)
7ecd2aa Fix generator rebuild and release pipeline
```
## Открытые вопросы
- [ ] docs-generator: `map-fixed` → `= { ... }`, `array-map-fixed` → JSON/jsonencode
(см. `docs/TODO/docs_generator_nested_attr_syntax.md`).
- [ ] Проверить `IsJson`-поля без дефолта: тот же класс unknown-after-apply? (не воспроизводилось).
- [ ] `fast` в тест-фикстуре `provider/internal/core/client_test.go:287` и спек-доке
`docs/60_strategy/...:158` — не трогали.
@@ -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,252 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Архитектура отменённого захода (`kind: modifier` в YAML + реестр в генераторе). История.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Opus: архитектура модификаторов (project, полный) — 2026-09-22
Источник: ответ Opus на `prompt_for_opus_modifier_architecture_full.md`.
## Ключевая модель
Модификатор — **декларативная проекция подмножества полей родителя**, а не «действие».
Отсюда:
- один **reconcile** (Create ≡ Update), не два разных пути;
- payload всегда **полный по своим полям** (не дельта);
- источник истины — родитель; модификатор в state хранит только read-back.
---
## 1. Сравнить и применить — полный payload, не дельта
Дельта запрещена: бэкенд трактует отсутствующий параметр как reset-to-default (класс A).
Reconcile:
1. взять все `SchemaParams`;
2. заданные пользователем → значение из плана;
3. незаданные → live → default (уже в `operation_run_bycode.go`);
4. drift в Read — сравнение модели с `state_params` родителя (`state_refresh.go`).
## 2. Досылка незаданных (заливы A и B) — канон
`CompactParams` в шаблоне + досылка в клиенте — два конца одного бага.
Правило по приоритету (уже в `operation_run_bycode.go:98-118`):
| Ситуация | Что слать |
|---|---|
| задан пользователем | значение из плана |
| не задан, есть live ParamValue | live |
| не задан, нет live, есть DefaultValue | дефолт |
| не задан, ничего нет | **пропустить** (не синтезировать) |
**Дыра:** `CompactParams` в `modifier.go:92` выкидывает пустые ДО клиента (теряется
«задал пусто» vs «не задал»). → Убрать `CompactParams` из шаблона модификатора,
передавать map напрямую. Единственная точка решения — клиент. `CompactParams`
оставить только для instance-ресурсов.
## 3. Delete / rollback
No-op Delete = скрытый drift (класс D). Пока обратный payload не подтверждён —
допустимы 3 стратегии через флаг YAML `delete_strategy`:
1. `noop_warn` — удалить из state + `AddWarning` (дефолт для необратимых: `ip_space`);
2. `inverse` — если есть «выключающие» значения в modify (напр. `needEnableAVI:false`);
3. `error` — запретить destroy (`AddError`), если откат критичен.
Обратный payload — та же modify с выключающими значениями. Для `ip_space` его нет → только `noop_warn`.
## 4. Idempotency + ID
- ID = **идентичность** (родитель + имя модификатора) = `instanceUID:modifierName`.
Это правильно и не должен меняться per-apply. opUid в ID **не класть** (иначе replace).
opUid — только в лог/приватный state.
- **Двойная аллокация (класс E)** защищается не ID, а **идемпотентностью modify**:
pre-check «desired == current» → пропустить run. Для `ip_space` перед modify читать
`state_params`; если целевое достигнуто — skip.
## 5. Связь с родителем
- `<service>_id` — ссылка на родителя (Required, уже так). `depends_on` не нужен —
пользователь передаёт UUID.
- Borrow state не нужен: Read тянет `state_params` родителя по UUID.
- Родителя нет (`ShouldRemoveFromState`) → модификатор удаляется из state (уже есть).
## 6. Create vs Update
Единый `reconcile(ctx, plan)`; Create и Update вызывают его (устраняет дубль веток).
## 7. Полный перечень кейсов (13 шт)
| # | Кейс | Поведение |
|---|---|---|
| 1 | create родителя → create модификатора | reconcile, полный payload |
| 2 | изменение одного поля | полный payload, соседние не сбрасываются (A) |
| 3 | partial params | досылка live→default→skip (B) |
| 4 | `integer > 0` без значения/дефолта | пропустить (не слать `"0"`) |
| 5 | `is_modifiable:true` (`needEnableAVI`) | не CreateOnly, менять без replace (C) |
| 6 | destroy модификатора | по `delete_strategy` (D) |
| 7 | replace/taint | reconcile + idempotency pre-check (E) |
| 8 | повторный apply без изменений | desired==current → skip |
| 9 | родитель удалён | remove из state |
| 10 | API не вернул код в state_params | unknown→null (уже) |
| 11 | два модификатора разных типов | разные ID |
| 12 | operation in progress | waitForInstanceIdle (уже) |
| 13 | drift на платформе | Read → план показывает изменение |
## Сводка мест правки
| Место | Правка |
|---|---|
| `modifier.go:92` | убрать `CompactParams` → прямой map (п.2) |
| `modifier.go:77` | единый `reconcile()` (п.6) |
| `modifier.go:156` | `delete_strategy` (п.3) |
| `modifier.go:100` | ID = `instanceUID:modifierName` (п.4) |
| `RunOperationByCodeWithTimeout` / reconcile | idempotency pre-check (п.4,7) |
| `params.go:116` | учитывать modifier-канал/`is_modifiable` (класс C, кейс 5) |
| loader модификаторов | YAML-поля `delete_strategy`, `idempotency` |
| `operation_run_bycode.go` | оставить как есть (guard корректен) |
## Открытые вопросы к Opus (не закрыты ответом)
1. **Где брать значения для `inverse`-стратегии Delete?** Для `network` «выключающие»
значения — это хардкод per-modifier? Как их задать декларативно в YAML, без хардкода
в генераторе?
2. **Формат YAML новых полей.** Точная схема `delete_strategy` и `idempotency`:
enum-значения, дефолты, валидация (fail-fast на неизвестных).
3. **Pre-check «desired == current» — где читать current?** Через
`RefreshResourceState`/`state_params` или отдельный GET? Как сериализовать сравнение
для map-fixed/array-map-fixed (порядок ключей)?
4. **Как пометить модификатор «idempotency: check_before_run» на уровне YAML**
(а не хардкодом в коде reconcile)?
5. **Что если желаемое == текущее, но была «частичная» ошибка ранее** — пропускать run
безопасно всегда, или есть исключения?
---
# Ответы Opus №2 (уточнения по 5 вопросам)
## 1. inverse-Delete — только декларативно в YAML, хардкод запрещён
Обратный payload зависит от параметров: `needEnableAVI:false` валиден, а
`virtualServicesCount` (`integer > 0`) обнулить нечем → `0` невозможен.
Значит inverse-значения задаются **явным блоком в YAML**. Если хоть один параметр
не имеет валидного inverse — стратегия `inverse` недопустима (fail-fast в загрузчике).
Для `ip_space` inverse нет вообще → только `noop_warn`.
## 2. Точная схема YAML новых полей
```yaml
operations:
- kind: modifier
modifier: network
action: modify
delete_strategy: noop_warn # enum: noop_warn | inverse | error
idempotency: check_before_run # enum: none | check_before_run
delete_params: # обязателен ТОЛЬКО при delete_strategy: inverse
- code: needEnableAVI
value: "false"
params: [...]
```
Go-контракт (`OperationSpec`):
```go
DeleteStrategy string `yaml:"delete_strategy,omitempty"` // "" → noop_warn
Idempotency string `yaml:"idempotency,omitempty"` // "" → none
DeleteParams []ParamSpec `yaml:"delete_params,omitempty"`
```
Дефолты: `delete_strategy` → `noop_warn`; `idempotency` → `none`.
Fail-fast в `ValidateSpec`: значение вне enum → ошибка; `inverse` с пустым
`delete_params` → ошибка; `delete_params.code` нет в `params` → ошибка; inverse-значение
нарушает constraint параметра → ошибка на этапе генерации.
`GenModifier` получает `DeleteStrategy`, `Idempotency`, `DeleteParams`.
## 3. Откуда читать current + как сравнивать
**Читать из `state_params`, отдельный GET не делать** (это уже источник истины для Read;
второй источник = риск рассогласования).
Сравнение по типу:
| Тип | Как сравнивать |
|---|---|
| bool/int/string | равенство после `normalizeUniversalValueV6` |
| map-fixed | `JSONStringsEquivalent` (игнор порядка ключей) |
| array-map-fixed | deep-equal с сохранением **порядка элементов** (порядок значим) |
Порядок ключей map-fixed — нормализовать (не значим). Порядок элементов
array-map-fixed — НЕ нормализовать (значим).
## 4. idempotency декларативно
Поле `idempotency` на modify-операции в YAML → `ValidateSpec` → `GenModifier.Idempotency`
→ шаблон `modifier.go` в `reconcile()` эмитит pre-check `{{- if eq .Idempotency "check_before_run" }}`.
Для `ip_space` — в YAML; для остальных — дефолт `none`.
## 5. Когда безопасно skip run при desired == current (НЕ всегда)
Три условия безопасного skip:
1. **Инстанс idle** — если pending/in-progress, сначала `waitForInstanceIdle`, потом
перечитать `state_params` (иначе mid-flight аллокация даст ложное «уже равно»).
2. **current из живого state_params, НЕ из TF-state** — после частичной ошибки TF-state
может врать, а state_params отражает реальную платформу.
3. **Сравнение по всем полям, не по одному** — skip только при совпадении ВСЕХ полей.
Итог:
```
idle? нет → wait, re-read
всё-live == всё-desired? да → skip run
иначе → reconcile (полный payload)
```
---
# Ответы Opus №3 (сверка с фактическим кодом, расхождения + сомнения)
## Факт №1: контракт — в lib, поведение — в GenModifier
Подтверждено: `delete_strategy`/`idempotency`/`delete_params` добавляются в
**`lib.OperationSpec`** (`TOOLS/lib/types.go`), resource-generator получает через алиас
(`types.go:19`). Ссылка «types.go:39» была неточной — канон в lib.
Граница:
| Где | Что |
|---|---|
| `lib.OperationSpec` / `lib.ParamSpec` | всё из YAML, видно обоим генераторам |
| `GenModifier` (локально) | производные для шаблона, флаги `Needs*`, готовый inverse-список |
Правило: парсится из YAML → lib; вычисляется загрузчиком для шаблона → GenModifier.
## Факт №2: pre-check — в `core` (вариант A), экспортировать сравнение
`normalizeUniversalValueV6` приватная и требует `universalCfsParam` — в `resources_core`
этих данных нет. Pre-check делать **в `core`**, не в resources_core и не в шаблоне.
Конкретно — экспортированный метод в `core`, вызывается из `operation_run_bycode.go`
сразу после `fetchOperationCfsParams`:
```go
func (c *UniversalClient) modifierDesiredEqualsCurrent(
desired map[string]string, cfsParams []universalCfsParam) bool
```
Сравнение: нормализовать обе стороны через `normalizeUniversalValueV6`; для
map-fixed/array-map-fixed — JSON-эквивалентность. Но `JSONStringsEquivalent` лежит в
`resources_core` → импорт в `core` даст **цикл**. Вынести JSON-эквивалентность в
нейтральный пакет (`core/jsonutil` или в сам `core`) и переиспользовать в обоих местах.
Вариант C (только `JSONStringsEquivalent` без нормализации) — **отклонён** (ложный diff
`true`/`1`).
Управление: `RunInstanceOperationUniversalByCode` получает флаг `idempotent` (из
`GenModifier.Idempotency` → шаблон → параметр вызова); idle-гейт выше pre-check.
## Дополнительные сомнения (ответы)
1. **Idempotency и полный payload НЕ конфликтуют** (разные уровни). Бинарно на весь
модификатор: `ALL == ALL` → skip целиком; любое расхождение → полный payload.
Полудельты нет.
2. **Частичный inverse — допустим и правилен.** `delete_params` покрывает только
обратимые поля; необратимые/constraint просто не входят. Fail-fast смягчить:
ошибка не «inverse обязан покрыть всё», а «код в delete_params обязан существовать
в params и value удовлетворять constraint». Delete при inverse = modify с
delete_params + досылка live остальных (полный payload).
3. **`noop_warn` дефолт — оставить, но критичные — вручную `error`.** Дефолт мягкий
(`noop_warn`, всегда с `AddWarning`), а необратимые (`ip_space`) автор спеки явно
помечает `delete_strategy: error` в YAML. Генератор сам не решает обратимо/необратимо.
@@ -0,0 +1,59 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Баг отменённого захода (`kind: modifier` в YAML + реестр в генераторе). История.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Opus-разбор: modify-модификатор сбрасывает create-поля в дефолт — 2026-09-22
Источник: ответ Opus на `prompt_for_opus_modifier_null_bug.md`.
## Симптом
`nubes_vc_nsxt_network` (modifier vc_nsxt.network, modify 111) после create Edge с
`needEnableAVI=true`, `virtualServicesCount=3` сбрасывал `needEnableAVI` на платформе
обратно в `false`.
## Корень бага (подтверждено cfsParams операций)
Два пути отправки modify ведут себя по-разному:
- **Generic modify** (`UpdateResource` → `RunInstanceOperationUniversalWithDefaults`,
`client.go:493`) — в цикле дозаполнения шлёт **все** незаданные cfsParams их текущим
`ParamValue` (или `DefaultValue`) — безусловно.
- **Модификатор** (`RunOperationByCodeWithTimeout` → `RunInstanceOperationUniversalByCode`,
`client.go:1518`) — в аналогичном цикле стоял guard `if !param.IsRequired { continue }`,
который пропускал опциональные параметры.
`needEnableAVI` — опциональный параметр modify 111 и не входит в `SchemaParams` модификатора
`vc_nsxt.network` (там только SNAT/routedNetConfiguration). Итог:
1. модификатор его не шлёт (не его поле);
2. back-fill его пропускает (`IsRequired == false`);
3. бэкенд видит отсутствующий параметр → трактует как reset-to-default → `false`.
`CompactParams` тут ни при чём для `needEnableAVI` — параметр вообще не был в payload модификатора.
## Ответы Opus
1. **Полный или частичный payload?** Канон — полный: все параметры операции, незаданные
дозаполняются текущим live-значением (`ParamValue`). Бэкенд для modify трактует
пропущенный/null как reset-to-default, поэтому частичный payload обязан затирать create-поля.
2. **Где чинить?** В `RunInstanceOperationUniversalByCode` — убрать `IsRequired`-guard в цикле
дозаполнения (стало: слать ВСЕ незаданные params их live-значением, как в `WithDefaults`).
- НЕ в `CompactParams` (он не видит полный набор cfsParams, только поля модификатора).
- НЕ в шаблоне генератора (шаблон тоже не знает полного набора).
3. **Риск для vc_org.ip_space:** основной live-путь безопасен (досылка идёт **текущим** значением,
не хардкод-дефолтом). На fallback-пути `/instanceOperations/default/{opId}` `ParamValue` пуст —
есть только `DefaultValue`; но тот же риск уже несёт `WithDefaults`, новой регрессии нет.
## Внесённый фикс
`provider/internal/core/client.go` — `RunInstanceOperationUniversalByCode`, цикл дозаполнения:
убраны `if !param.IsRequired { continue }` и `if !param.IsRequired && val == "" { continue }`.
Теперь все незаданные параметры modify досылаются их live-значением (или default).
Коммит: `c420ea0`.
## Примечание
Костыль в `DEV_STAND/FullPipe/edge_network.tf` (явная передача ALB/VS/qos в модификаторе)
после фикса ядра становится избыточным, но не вреден. После пересборки провайдера можно
убрать эти три поля из `edge_network.tf` — досылка теперь происходит автоматически.
@@ -0,0 +1,76 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Ревью плана отменённого захода (`kind: modifier` в YAML + реестр в генераторе). История.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Opus: ревью плана редизайна модификаторов — 2026-09-22
Источник: ответ на `prompt_for_opus_modifier_plan_review.md` (план `PLAN_modifier_redesign.md`).
## 1. Порядок шагов — скрытые зависимости
- Шаг 4 (шаблон) ссылается на API из шагов 6–7 → **сначала 5→6→7, потом 4**.
- Шаг 8 (yaml-generator) должен идти ДО регенерации `dev` и до сборки.
Скорректированный порядок: 1 → 2 → 3 → 5 → 6 → 7 → 4 → 8 → регенерация → 9 → 10.
## 2. Шаг 5 (вынос JSON-эквивалентности)
Путь верен. **Оставить реэкспорт-обёртку `JSONStringsEquivalent` в `json_normalize.go`**,
не заменять вызовы по всему resources_core (иначе диф на инстансы, вопрос 7).
Переносятся самодостаточные 5 функций: `JSONStringsEquivalent`, `normalizeJSONIfPossible`,
`encodeCanonicalJSON`, `writeCanonicalJSON`, `normalizeJSONScalarsToStrings`.
Вариант «готовые строки в core» — отклонить (размазывает нормализацию, не снимает
потребность в JSONStringsEquivalent в core).
## 3. Шаг 6 — сигнатура и сравнение
- Маппинг code→param по **двум** алиасам: `p.Code` И `p.SvcOperationCfsParam` (как в
operation_run_bycode.go:50-58). Один `Code` даст пропуски.
- Имя `modifierDesiredEqualsCurrent` — unexported, вызов внутри core. Слово «экспортированный» убрать.
- bool/int/string — `normalizeUniversalValueV6` + сравнение. map-fixed — `JSONStringsEquivalent`.
- **array-map-fixed — дыра:** `normalizeUniversalValueV6` строит дефолт только для
`map-fixed`/`HasPrefix "map"` (params.go:33); `array-map-fixed` туда не попадает →
сравнивать сырые значения через `jsonutil.JSONStringsEquivalent`, не через normalize.
- desired = только явно заданные коды (до досылки live/default), иначе pre-check всегда «равно».
## 4. Шаг 4.4 Delete=inverse — подводный камень
- Delete не имеет `plan` (только `req.State`). `reconcile(ctx, plan *Model)` не подходит.
→ `reconcile(ctx, model *Model, override map[string]string)`; для inverse override = delete_params.
- `deleteParams` — финальные **wire-строки** (`"false"`, готовый JSON), БЕЗ прогонки через
`ParamFormat`/тип. В реестре `deleteParam{Code, Value}` несёт готовую строку.
## 5. Шаг 8 — расширение реестра
Верно. Держать в `serviceSpecificModifiers` (main.go:34), не отдельным реестром.
Структура `modifierException` корректна. При переходе со `map[string]string` на структуру:
`ModifierName` берётся из структуры (сейчас `modName, ok := serviceSpecificModifiers[name]`
— строка 95).
## 6. Пропущенные кейсы
- **taint/replace + `delete_strategy=error`** — конфликт: replace = Delete→Create, Delete=error
блокирует → пользователь не сможет заменить error-модификатор. Решить явно:
запретить replace у error (документировать) или отличить «чистый destroy» от replace.
- **unknown в pre-check** — при unknown (computed ref) сравнение невозможно; шаг 6 должен
skip-ить pre-check при unknown (иначе пустая строка даст ложный diff/панику).
- **partial apply** — досылка live для незаданных + pre-check; проверить кейс «часть задана, часть live».
## 7. Риск сломать инстансы
Низкий при условиях:
- **НЕ удалять `CompactParams`** (helpers.go:68) — убирается только из modifier-шаблона;
функция нужна инстанс/action.
- **Шаг 7 — новый метод `RunOperationByCodeIdempotent`, НЕ менять сигнатуру**
`RunOperationByCodeWithTimeout`/`RunInstanceOperationUniversalByCode` (зовут инстансы).
- Шаг 1 (поля OperationSpec) аддитивен — безопасно.
## Дополнительно (не в вопросах)
- **Шаг 4.3 (ID=identity) — ломающая миграция state.** Смена формата ID изменит ID уже
задеплоенных модификаторов → Terraform форснёт replace. Нужно: либо сохранить старый
формат ID, либо явный state-migration plan. В плане не отмечено.
- **Шаг 3 — `normalizeDeleteStrategy`/`normalizeIdempotency`** — где живут (в loader.go, рядом
с веткой modifier). Не указано.
- **ValidateSpec** — проверка `delete_params.code ∈ op.Params` по lower-code; сверить поле `Code`.
@@ -0,0 +1,43 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Код-ревью отменённого захода (`kind: modifier` в YAML + реестр в генераторе). История.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Opus-код-ревью: модификаторы (kind: modifier) — 2026-09-22
Источник: ревью по `prompt_for_opus_modifiers_review.md`.
Объекты: `nubes_vc_org_ip_space` (vc_org.ip_space, modify 207) и `nubes_vc_nsxt_network` (vc_nsxt.network, modify 111).
Файлы: шаблон `TOOLS/resource-generator/internal/templates/modifier.go`, loader, `generated/dev/go/19_vc_org_ip_space_modifier.go`, `22_vc_nsxt_network_modifier.go`, `registry.go`, `provider/internal/resources_core/crud.go` (RunOperationByCodeWithTimeout), `provider/internal/core/client.go` (RunInstanceOperationUniversalByCode, RefreshResourceState, ShouldRemoveFromState).
## 1. Жизненный цикл Create/Update/Read/Delete
- **Create ≡ Update**: оба тела идентичны — гонят `modify` с текущими params. Любое изменение любого атрибута = повторный запуск `modify` целиком, не дельта.
- **Идемпотентность на платформе, не в провайдере.** Нет сравнения «до/после», нет проверки, что операция применила именно эти значения (только `validate-cfs` + `run`). Если API-`modify` аккумулирует, а не перезаписывает (особенно `vIPConfigure`) — повтор = дубли/лишний расход квоты.
- **Refresh (Read) частичный и потенциально вредный.** `RefreshResourceState` тянет `state_params` и перезаписывает input-поля:
- если платформа не эхоит код в `state_params` (типично для операционных modify-параметров) — дрейф не детектируется, Read почти no-op → управление «вслепую»;
- если эхоит, но нормализованно (bool как `1/0`, порядок ключей в `routedNetConfiguration`/`map-fixed`) — вечный diff. `JsonNormalize()` стоит только как plan-modifier на Required-строке; ветка `map-fixed` в refresh json-нормализацию не гарантирует.
## 2. Delete = no-op — ожидаемо? Подводные камни
No-op ожидаем (обратного payload нет). Но:
- **destroy убирает ресурс из state, оставляя эффект на платформе** (выделенные IP, включённый AVI/LB). Инфраструктура и state расходятся молча.
- **Самый опасный сценарий — taint/replace или destroy→apply**: Create снова гонит `modify` → повторное выделение внешних IP (`nubes_vc_org_ip_space`). Прямой риск двойного выделения и расхода.
- `BuildActionID(instanceUID, "modify", "ip_space")` — детерминированный константный ID, не привязан к реальному opUid. State не отражает, какая операция и с какими значениями отработала; два модификатора одного типа на одном инстансе получили бы одинаковый ID.
## 3. Риски передачи по коду (code → id) в RunInstanceOperationUniversalByCode
- **Резолв code→id полностью зависит от `GET /instanceOperations/{opUid}?fields=cfsParams`** — того запроса, что даёт 500 на проблемных инстансах. Без fallback модификатор неработоспособен целиком (без словаря `codeToParam` параметры не отправить).
- **Коды захардкожены в сгенерированном коде** (`vIPConfigure`, `needEnableAVI`…). Переименование на платформе ломается в рантайме («код параметра X не найден»), а не на компиляции — молчаливая деградация.
- **Частичный payload = скрытые сайд-эффекты.** `CompactParams` выкидывает пустые Optional. Для `modify` пропуск параметра платформа может трактовать как «сбросить в дефолт» (не задал `needEnableAVI` → LB может выключиться). Семантика PATCH vs PUT не контролируется провайдером.
- opId ищется среди `AvailableOperations`: не то состояние инстанса → жёсткий отказ «операция недоступна». `LockInstance` сериализует операции по инстансу — конкурентность закрыта корректно.
## ТОП-3 критичных
1. **Двойное выделение при replace/destroy→apply** (особенно `ip_space`): no-op Delete + повторный `modify` на Create + отсутствие проверки идемпотентности = риск задвоить внешние IP/квоту. Нужен guard перед `modify` (проверка по `state_params`/наличию ресурса), либо явно документировать запрет replace.
2. **Refresh либо слепой, либо вечный diff.** Для операционных modify-параметров `state_params` обычно их не возвращает → Read ничего не сверяет; там где возвращает — нормализация (bool/JSON `map-fixed`) ломает план. Решить: честный drift-refresh с нормализацией, либо явно пометить поля как не-refreshable.
3. **Жёсткая зависимость от падающего `?fields=cfsParams`.** code→id держится на запросе, который 500-тит на проблемных инстансах — модификатор ложится целиком. Fallback на `/instanceOperations/default/{opId}` — условие работоспособности, а не «приятная опция».
## Мелочи
- Константный `BuildActionID` — ID не привязан к реальной операции.
- Захардкоженные коды ломаются в рантайме, а не на сборке.
+142
View File
@@ -0,0 +1,142 @@
# DevOps Runbook: Provider Build Pipeline
> Перенесено из корневого `README.md` 2026-09-24 (в корне теперь — карта проекта).
> Пути и версии в тексте приведены к текущему состоянию репозитория.
Пайплайн сборки провайдера. Скрипты живут в `TOOLS/scripts/` (НЕ в корне репозитория).
## Overview
1) Generate YAML specs from API
2) Generate Go resources + documentation files from YAML
3) Build and upload provider binaries for 3 OS targets
4) Build and publish documentation site
## Documentation publishing instructions
The verified documentation generation and publishing pipeline is documented in
[`../HISTORY/2026-09-03_docs_upload_pipeline_verified.md`](../HISTORY/2026-09-03_docs_upload_pipeline_verified.md).
It covers the generated docs source, MkDocs build, the separate documentation
S3 bucket, VM upload and mirror steps, stand-specific URLs, and the legacy
script that must not be used.
## Prerequisites
- Go 1.22+
- `python3`
- `gpg`
- `mc` (MinIO/S3 client)
- Docker (for mkdocs build)
## Shared settings
S3 environment:
- `S3_ENDPOINT` (example: `https://s3.msk-1.ngcloud.ru`)
- `S3_ACCESS_KEY`
- `S3_SECRET_KEY`
Provider naming defaults:
- `REGISTRY_HOSTNAME`: `tf-registry.containerk8s.services.ngcloud.ru`
- `NAMESPACE`: `nubes`
- `NAME`: `nubes`
## Step 1: Generate YAMLs from API
Script: `TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/<стенд>`
Input list of services:
- `TOOLS/config/services_list.txt` (service_id only)
Token options:
- `TOKEN_FILE=/home/naeel/terra/HH-MM-SS.token`, or
- `NUBES_API_TOKEN` directly
Example:
```bash
export TOKEN_FILE=/home/naeel/terra/08-33-41.token
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev
```
## Step 2: Generate Go resources and docs
Script: `TOOLS/scripts/02_generate_resources_and_docs_v2.sh`
Example:
```bash
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev
```
Outputs:
- Go files in `generated/<stand>/go`
- Docs in `generated/<stand>/docs`
Important:
- The v2 script always rebuilds `resource-generator` and `docs-generator` from source before running.
- Do not invoke stale binaries from `TOOLS/resource-generator/bin/` or `TOOLS/docs-generator/bin/` directly.
## Step 3: Build and upload provider
Script: `03_build_and_upload_provider.sh`
Uses `registry-server-build/build-provider.sh` and signs with:
- `secrets/private_key.asc` (ignored by git)
Example:
```bash
export S3_ENDPOINT=https://s3.msk-1.ngcloud.ru
export S3_ACCESS_KEY=...
export S3_SECRET_KEY=...
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.18
```
## Step 4: Build and publish docs
Script: `04_build_and_publish_docs.sh`
Example:
```bash
export S3_ENDPOINT=https://s3.msk-1.ngcloud.ru
export S3_ACCESS_KEY=...
export S3_SECRET_KEY=...
./TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/dev 2.0.18
```
## Notes
- The GPG private key must remain stable across releases. Do not regenerate per build.
- If the key is regenerated, the registry server must be updated to serve the new public key.
- Terraform will fail with `authentication signature from unknown issuer` if the registry public key does not match the signing key.
- `TOOLS/config/services_list.txt` — источник правды по тому, какие сервисы генерируются.
- Если меняется версия провайдера — обновить `provider/main.go` (ранее `universal_rebuild/main.go` — устаревший путь).
## One-time GPG bootstrap (do this once, keep the key stable)
1) Generate and export keys (no passphrase):
```bash
GPG_DIR=${ROOT_DIR}/secrets
GNUPGHOME=$(mktemp -d)
cat > /tmp/gpg_batch <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Subkey-Type: RSA
Subkey-Length: 4096
Name-Real: tazet@narod.ru
Name-Email: tazet@narod.ru
Expire-Date: 0
EOF
gpg --batch --homedir "$GNUPGHOME" --gen-key /tmp/gpg_batch
gpg --batch --homedir "$GNUPGHOME" --armor --export-secret-keys > "$GPG_DIR/private_key.asc"
gpg --batch --homedir "$GNUPGHOME" --armor --export > "$GPG_DIR/public_key.asc"
rm -rf "$GNUPGHOME" /tmp/gpg_batch
```
2) Update registry server public key (ASCII Armor) in:
- `registry-server-build/main.go`
- `operator/cmd/registry/main.go`
3) Rebuild and redeploy the registry server (see `docs/50_history/00_system_mechanics.md`).
4) Build and upload provider artifacts as usual.
# check string
+19 -15
View File
@@ -4,11 +4,15 @@
**Первая цифра версии жёстко привязана к стенду. НЕ ПУТАТЬ.**
| Стенд | Namespace | Первая цифра | Профиль |
| Стенд | Namespace | Диапазон | Профиль |
|---|---|---|---|
| **PROD** | `nubes` | `2.*` | `TOOLS/config/prod` |
| **DEV** | `nubes-dev` | `3.*` | `TOOLS/config/dev` |
| **TEST** | `nubes-test` | `5.*` | `TOOLS/config/test` |
| **PROD** | `nubes` | `1.*` | `TOOLS/config/prod` |
| **DEV** | `nubes-dev` | `2.*` | `TOOLS/config/dev` |
| **TEST** | `nubes-test` | `3.*` | `TOOLS/config/test` |
> ⛔ ЛЕГАСИ (не использовать): `prod=2.*`, `dev=3.*`, `test=5.*`, `0.0.1`.
> Примеры версий ниже в этом файле могут содержать легаси-номера — подставляйте актуальную
> из `../VERSIONS.md`.
## Архитектура конфигурации
@@ -23,7 +27,7 @@ TOOLS/config/
│ NUBES_API_ENDPOINT = ...dev...
│ TOKEN_FILE = secrets/dev.token
│ NAMESPACE = nubes-dev
│ VERSION = 3.x.x
│ VERSION = 2.x.x ← актуальную брать из VERSIONS.md
│
├── test/profile.env
└── prod/profile.env
@@ -65,7 +69,7 @@ cd ~/tf_provider
### Шаг 3 — Собрать и залить в реестр
```bash
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 3.1.13
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.18
```
Компилирует (linux/windows/darwin), подписывает GPG, заливает в S3.
@@ -75,7 +79,7 @@ cd ~/tf_provider
```bash
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev && \
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev && \
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 3.1.13
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.18
```
## Быстрая заливка (без перегенерации YAML/Go)
@@ -83,14 +87,14 @@ cd ~/tf_provider
Если YAML'ы и Go-код уже сгенерированы и не менялись — только шаг 3:
```bash
# DEV
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 3.1.13
# DEV (2.*)
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.18
# TEST
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/test 5.1.17
# TEST (3.*)
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/test 3.0.1
# PROD
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/prod 2.1.23
# PROD (1.*)
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/prod 1.0.1
```
Креды S3 подхватываются из `secrets/.s3cfg_registry`. Или через env:
@@ -129,7 +133,7 @@ curl -s https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/nubes/
## Актуальные версии
Файл [`VERSIONS.md`](VERSIONS.md) — единственный источник правды. После каждой заливки — обновить.
Файл [`../VERSIONS.md`](../VERSIONS.md) — единственный источник правды. После каждой заливки — обновить.
## Terraform-конфиг пользователя
@@ -138,7 +142,7 @@ terraform {
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "3.1.13"
version = "2.0.18"
}
}
}
@@ -1,5 +1,12 @@
# План миграции в Nubes Managed Kubernetes — Инструкции для агента
> ⚠️ **Пути `universal_rebuild/*` в этом плане — от ПРЕЖНЕЙ раскладки репозитория.** Актуально:
> `universal_rebuild/internal/*` → `provider/internal/*`; `universal_rebuild/resources_yaml` →
> `generated/<стенд>/resources_yaml`; `universal_rebuild/tools/gen` → `TOOLS/resource-generator`.
> Также план писался ДО смены схемы версий: актуально `prod=1.*`, `dev=2.*`, `test=3.*`.
> Часть про миграцию `registry.kube5s.ru` → `registry.nubes.ru` — ИСТОРИЧЕСКАЯ: актуальный реестр
> `tf-registry.containerk8s.services.ngcloud.ru` (бакет `nubes-terraform-registry`).
**Создан:** 2026-03-13 (Opus 4.6)
**Исполнитель:** Sonnet 4.6
**Статус:** Ожидает исполнения
@@ -24,7 +31,7 @@ Nubes (nubes.ru) — российский cloud-провайдер, собств
**Обязательно прочитать перед работой:**
- `REPO_CONTENTS.md` — карта репозитория
- `.github/copilot-instructions.md` — правила работы (IMMUTABILITY POLICY)
- `docs/CODEBASE_ANALYSIS_AND_ROADMAP.md` — анализ кодовой базы
- `NOTES/30_analysis/CODEBASE_ANALYSIS_AND_ROADMAP.md` — анализ кодовой базы
---
+66
View File
@@ -0,0 +1,66 @@
# HOW_TO — все инструкции проекта
Здесь лежат **общие инструкции**: как собрать/залить провайдер, как добавить сервис, как устроены
процессы. Отсюда начинать, если нужно что-то «сделать руками».
> Публикуемая пользовательская документация — в `../docs/` (mkdocs).
> Рабочие материалы (планы, промпты, анализы) — в `../NOTES/`.
---
## Индекс: что нужно → какой файл
| Нужно | Файл | Кому |
|---|---|---|
| **Собрать и залить провайдер** (YAML → Go → бинарник → S3) | [`HOWTO-UPLOAD.md`](HOWTO-UPLOAD.md) | Релиз-инженеру |
| **Полный DevOps-ранбук пайплайна** (4 шага: генерация, ресурсы+доки, сборка, публикация доков) + GPG-bootstrap | [`DEVOPS_BUILD_PIPELINE.md`](DEVOPS_BUILD_PIPELINE.md) | DevOps |
| **Добавить новый сервис** в провайдер (полный цикл) | [`HOWTO_ADD_NEW_SERVICE.md`](HOWTO_ADD_NEW_SERVICE.md) | Разработчику провайдера |
| **Имплементировать новый managed-сервис** (со стороны облака) | [`HOWTO_IMPLEMENT_NEW_CLOUD_SERVICE.md`](HOWTO_IMPLEMENT_NEW_CLOUD_SERVICE.md) | DevOps облака |
| **Понять, как всё устроено на практике** (закрытый developer-guide) | [`howitwasdone.md`](howitwasdone.md) | Разработчику провайдера |
| **Генерация документации** (архитектура, пайплайн, правила для LLM) | [`LLM_DOCS_GENERATION.md`](LLM_DOCS_GENERATION.md) | Разработчику доков |
| **План миграции + runbook реестра** (обновление, откат, troubleshooting, мониторинг) | [`MIGRATION_PLAN_FOR_AGENT.md`](MIGRATION_PLAN_FOR_AGENT.md) | Агенту/инженеру |
---
## Короткий путь: собрать и залить (3 шага)
```bash
cd /home/naeel/TF/tf_provider
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.18
```
Полные детали, требования, проверка после заливки и структура S3 — в [`HOWTO-UPLOAD.md`](HOWTO-UPLOAD.md).
## Текущие версии (источник правды)
[`../VERSIONS.md`](../VERSIONS.md). Схема нумерации: **prod = `1.*`, dev = `2.*`, test = `3.*`**
(легаси `prod=2.*`, `dev=3.*`, `test=5.*`, `0.0.1` — НЕ использовать). Обоснование схемы:
[`../NOTES/10_plans/PLAN_FLASH_reversion_cleanup.md`](../NOTES/10_plans/PLAN_FLASH_reversion_cleanup.md).
---
## Где лежит остальное (чтобы не искать вслепую)
| Тема | Где |
|---|---|
| Операционные runbook'и (API-токены, стенды, мониторинг, откат, тестирование, реестр) | `../docs/ops/` |
| Внутренние справки/разборы по сборке и архитектуре | `../docs/help/` (напр. `BUILD.md`, `build-and-publish.md`) |
| Пайплайн публикации документации | `../DOCS_PIPELINE/README.md`, `../DOCS_PIPELINE/publish-docs.sh` |
| Правила генерации кода провайдера (ОБЯЗАТЕЛЬНЫ для генератора) | `../TOOLS/ARCHITECTURE.md` |
| Скрипты пайплайна | `../TOOLS/scripts/` |
| Конфиги стендов и общий реестр | `../TOOLS/config/` (`registry.env`, `<стенд>/profile.env`, `services_list.txt`) |
| Секреты (не коммитить) | `../secrets/` |
| Текущая задача по IaC/`modify` | `../NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md` |
---
## ⛔ Частые грабли (не наступать)
- **Не вызывать** устаревшие бинарники `TOOLS/*/bin/` — скрипт `02_*` сам пересобирает генераторы.
- **Не путать** схемы версий: только `prod=1.*`, `dev=2.*`, `test=3.*`.
- **Не использовать** старый API `index.cfm` и хосты `registry.kube5s.ru` / `deck-api.ngcloud.ru` — закрыты.
- **GPG-ключ** подписи не перегенерировать: иначе registry и `terraform init` сломаются
(`authentication signature from unknown issuer`).
- **S3-бакеты разделены**: бинарники — `nubes-terraform-registry`, документация — `terraform-registry`.
@@ -2,6 +2,13 @@
<!-- Актуальный API: https://lk-api-gateway.ngcloud.ru/api/v1/svc -->
# How It Was Done — Developer Guide (закрытая страница)
> ⚠️ **Пути в этом документе — от ПРЕЖНЕЙ раскладки репозитория (`universal_rebuild/*`).**
> Актуальное соответствие: `universal_rebuild/internal/*` → `provider/internal/*`;
> `universal_rebuild/resources_yaml` → `generated/<стенд>/resources_yaml`;
> `universal_rebuild/tools/gen` → `TOOLS/resource-generator`;
> `universal_rebuild/tools/service_params_gen` → `TOOLS/yaml-generator`.
> Смысл описанного сохраняется, но пути в тексте сверять по этому соответствию.
**Filename & Versioning:** howitwasdone.md / 2026‑02‑04 / Draft v1
Этот документ — единый технический мануал. Он доступен только по прямой ссылке и не включён в публичную навигацию.
@@ -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) — отдельная тема.
+224
View File
@@ -0,0 +1,224 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Отменёный заход: доменная логика модификаторов вшивалась в универсальный генератор
> (`kind: modifier` в YAML + реестр в `yaml-generator`, `delete_strategy`/`inverse`). Ломало агностичность
> провайдера и порождало баги. Файл сохранён ТОЛЬКО как история, не источник истины.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# ПЛАН реализации: редизайн ресурсов-модификаторов (kind: modifier)
Основа: `HISTORY/OPUS/2026-09-22_modifier_architecture_project.md`.
Ревью плана: `HISTORY/OPUS/2026-09-22_modifier_plan_review.md`.
Цель — закрыть все классы багов A–E, без костылей, по согласованной архитектуре.
Порядок шагов (исправлен по ревью): шаблон (4) зависит от core/resources_core (5–7),
поэтому: 1 → 2 → 3 → 5 → 6 → 7 → 4 → 8 → регенерация → 9 → 10.
---
## Шаг 1. Контракт YAML в `TOOLS/lib/types.go`
Файл: `TOOLS/lib/types.go`, `OperationSpec`.
Добавить поля (тег yaml, omitempty):
```go
DeleteStrategy string `yaml:"delete_strategy,omitempty"` // "" → noop_warn
Idempotency string `yaml:"idempotency,omitempty"` // "" → none
DeleteParams []ParamSpec `yaml:"delete_params,omitempty"`
```
Enum `delete_strategy`: `noop_warn` | `inverse` | `error`.
Enum `idempotency`: `none` | `check_before_run`.
Проверка: `TOOLS/resource-generator` получает поля через алиас `OperationSpec = lib.OperationSpec` — отдельной правки не нужно, но `go build ./...` в lib и в resource-generator.
---
## Шаг 2. GenModifier — производные поля
Файл: `TOOLS/resource-generator/internal/types/types.go`, `GenModifier`.
Добавить:
```go
DeleteStrategy string // нормализованный enum (noop_warn|inverse|error)
Idempotency string // none|check_before_run
DeleteParams []Param // из spec.DeleteParams (ConvertParams), только при inverse
```
---
## Шаг 3. LoadSpecs — заполнение modifier + валидация
Файл: `TOOLS/resource-generator/internal/loader/loader.go`, ветка `op.Kind == "modifier"`.
До `continue`:
- `modifier.DeleteStrategy = normalizeDeleteStrategy(op.DeleteStrategy)` (пусто → `noop_warn`);
- `modifier.Idempotency = normalizeIdempotency(op.Idempotency)` (пусто → `none`);
- `modifier.DeleteParams = ConvertParams(op.DeleteParams)` (при inverse).
Helpers `normalizeDeleteStrategy`/`normalizeIdempotency` — добавить в `loader.go`
(тот же пакет, рядом с веткой modifier).
Файл: `loader.go`, `ValidateSpec` — расширить fail-fast для modifier:
- `delete_strategy` вне enum → ошибка;
- `delete_strategy == "inverse"` и пуст `delete_params` → ошибка;
- каждый `delete_params.code` обязан существовать в `op.Params` (сравнение по lower-code) → иначе ошибка;
- `idempotency` вне enum → ошибка.
---
## Шаг 4. Шаблон `modifier.go` — редизайн
Файл: `TOOLS/resource-generator/internal/templates/modifier.go`.
4.1. **Убрать `CompactParams`** — в Create/Update передавать map напрямую
(все заданные поля; решение о досылке — в core).
4.2. **Единый `reconcile()`** — вынести общее тело Create/Update в приватный метод
`reconcile(ctx, model *Model, override map[string]string)`, вызываемый из Create и Update
(override=nil). Устраняет дубль веток. **override нужен для Delete=inverse** (см. 4.4),
так как Delete не имеет plan — только state.
4.3. **ID = identity** — `plan.ID = BuildActionID(instanceUID, modifierName)`
(убрать operation из ID). Реализовать через существующий `BuildActionID(instanceUID, "", modifierName)`
или новый helper `BuildModifierID(instanceUID, modifierName)`.
⚠️ **миграция state:** смена формата ID изменит ID уже задеплоенных модификаторов →
Terraform форснёт replace. Принять решение ДО: сохранить старый формат ИЛИ явный
state-migration план. По умолчанию — сохранить формат `uid:operation:modifier`, не менять формат.
4.4. **Delete по стратегии**:
```
{{- if eq .DeleteStrategy "error" }}
Delete → AddError (запрет destroy); ⚠️ конфликт с replace: replace = Delete→Create,
при error пользователь не сможет заменить модификатор. Решение: запретить replace
у error-модификаторов (документировать) или отличить «чистый destroy» от replace.
{{- else if eq .DeleteStrategy "inverse" }}
Delete → reconcile(state-model, override=delete_params)
(delete_params — финальные wire-строки: "false", готовый JSON; обработать как override)
{{- else }}
Delete → RemoveResource + AddWarning («эффект остаётся на платформе»)
{{- end }}
```
4.5. **Pre-check idempotency** — в reconcile при `eq .Idempotency "check_before_run"`:
передавать флаг в вызов операции (см. шаг 6). ⚠️ при unknown (computed ref) pre-check
skip — сравнение невозможно.
---
## Шаг 5. JSON-эквивалентность в нейтральный пакет (снять цикл импорта)
Проблема: `JSONStringsEquivalent` в `resources_core`, а comparison нужен в `core`.
- создать `provider/internal/core/jsonutil/jsonutil.go`:
перенести `JSONStringsEquivalent` + `normalizeJSONIfPossible` + `encodeCanonicalJSON` +
`writeCanonicalJSON` + `normalizeJSONScalarsToStrings` из `resources_core/json_normalize.go`;
- `resources_core/json_normalize.go` — **оставить реэкспорт-обёртку** `JSONStringsEquivalent`
(не заменять вызовы по resources_core — иначе диф на инстансы).
Проверка: `go build ./...`, нет цикла импорта.
---
## Шаг 6. core — pre-check `modifierDesiredEqualsCurrent`
Файл: `provider/internal/core/operation_run_bycode.go` (или новый `modifier_compare.go`).
Добавить (unexported, вызов внутри core):
```go
func (c *UniversalClient) modifierDesiredEqualsCurrent(
desired map[string]string, cfsParams []universalCfsParam) bool
```
Логика:
- маппинг code→param по **двум** алиасам: `p.Code` И `p.SvcOperationCfsParam`
(как в operation_run_bycode.go:50-58);
- для каждого desired-кода → live `ParamValue`;
- bool/int/string → нормализовать обе стороны `normalizeUniversalValueV6` + сравнение строк;
- map-fixed → `jsonutil.JSONStringsEquivalent`;
- **array-map-fixed → `jsonutil.JSONStringsEquivalent` по сырым значениям, НЕ через normalize**
(`normalizeUniversalValueV6` не строит дефолт для array-map-fixed, params.go:33);
- desired — только явно заданные коды (до досылки live/default);
- если desired содержит unknown (computed ref) — сравнение невозможно, pre-check пропустить.
Опционально: добавить в `RunInstanceOperationUniversalByCode` параметр `idempotent bool`
(или новый метод-обёртка). В `operation_run_bycode.go` после `fetchOperationCfsParams`:
```
if idempotent && c.modifierDesiredEqualsCurrent(paramsByID, cfsParams) {
return nil // skip run
}
```
idle-гейт (`waitForInstanceIdle`) уже стоит выше — не трогать.
---
## Шаг 7. Передача флага `idempotent` вплоть до client
Цепочка: шаблон → `resources_core.RunOperationByCodeWithTimeout` → `core.RunInstanceOperationUniversalByCode`.
- **добавить НОВЫЙ метод `RunOperationByCodeIdempotent(...)` в `resources_core/crud.go`**,
НЕ менять сигнатуру `RunOperationByCodeWithTimeout` (его зовут инстансы);
- пробросить флаг в `RunInstanceOperationUniversalByCode` (новый параметр или обёртка).
---
## Шаг 8. YAML-разметка (источник-канон) в `TOOLS/yaml-generator`
Источник-канон — реестр исключений `serviceSpecificModifiers` в
`TOOLS/yaml-generator/main.go` (Ключ — имя сервиса → имя modifier).
`generated/dev` перегенерируется — туда НЕ вносить вручную.
Контракт в `lib.OperationSpec` (алиас в обоих генераторах), значит yaml-generator
должен проставлять флаги при маршале. Расширить реестр со `map[string]string`
до структуры, несущей: `ModifierName`, `DeleteStrategy`, `Idempotency`,
`DeleteParams []struct{Code,Value}`:
```go
type modifierException struct {
ModifierName string
DeleteStrategy string // noop_warn | inverse | error
Idempotency string // none | check_before_run
DeleteParams []deleteParam // только для inverse
}
type deleteParam struct { Code, Value string }
var serviceSpecificModifiers = map[string]modifierException{
"vc_org": {ModifierName: "ip_space", DeleteStrategy: "error", Idempotency: "check_before_run"},
"vc_nsxt": {ModifierName: "network", DeleteStrategy: "inverse",
DeleteParams: []deleteParam{{"needEnableAVI", "false"}}},
}
```
В цикле над ops (там, где `Kind="modifier"`): проставить `op.DeleteStrategy`,
`op.Idempotency`, `op.DeleteParams`.
⚠️ При переходе с `map[string]string` на структуру: `ModifierName` берётся из структуры
(сейчас `modName, ok := serviceSpecificModifiers[name]` — строка 95 main.go).
---
## Порядок коммитов (по смыслу)
1. `feat(lib): delete_strategy/idempotency/delete_params в OperationSpec`
2. `feat(gen): GenModifier расширение + LoadSpecs + ValidateSpec + normalize-helpers`
3. `refactor(core): вынести JSON-эквивалентность в jsonutil (+реэкспорт)`
4. `feat(core): modifierDesiredEqualsCurrent + RunOperationByCodeIdempotent`
5. `feat(gen): шаблон modifier — reconcile(override), Delete стратегия, ID identity`
6. `feat(yaml): реестр исключений модификаторов (delete_strategy/idempotency)`
7. `test(core,gen): unit-кейсы`
8. `chore(dev): bump версии`
---
## Открытый вопрос — закрыт
Источник-канон — реестр `serviceSpecificModifiers` в `TOOLS/yaml-generator/main.go`.
Разметка `delete_strategy`/`idempotency` расширяет этот реестр, а НЕ правится вручную
в `generated/dev`.
---
## Решения, нуждающиеся в подтверждении (из ревью)
1. **ID=identity → РЕШЕНО: формат ID НЕ меняем** (оставить `uid:operation:modifier`).
Смена формата форснёт replace у задеплоенных модификаторов и вызовет баг E.
Идемпотентность — через pre-check, не через ID. Шаг 4.3 отменён (ID остаётся как есть).
2. **`error` + replace.** Пользователь не сможет заменить error-модификатор.
Предлагаю: оставить `error` только для «чистого» destroy, документировать запрет replace.
3. **Разметка по default** — `ip_space`: `delete_strategy=error`, `idempotency=check_before_run`;
`network`: `delete_strategy=inverse`, delete_params=[needEnableAVI=false], idempotency=none.
@@ -1,3 +1,7 @@
> ⚠️ **ПЕРЕКРЫТ (пометка 2026-09-24). НЕ ИСПОЛЬЗОВАТЬ.**
> Версия `0.0.1` объявлена ЛЕГАСИ в `PLAN_FLASH_reversion_cleanup.md`.
> Актуальная схема: prod=`1.*`, dev=`2.*`, test=`3.*`.
# План: перегенерация провайдеров всех стендов (версия 0.0.1)
> Для Flash. Генерацию выполняет Flash по этому плану. Документацию НЕ трогать.
@@ -53,16 +57,16 @@ cd /home/naeel/TF/tf_provider
| Стенд | Namespace | Бинарники в S3 |
|---|---|---|
| dev | `nubes-dev` | `nubes-terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/0.0.1/` |
| test | `nubes-test` | `.../nubes-test/nubes/0.0.1/` |
| prod | `nubes` | `.../nubes/nubes/0.0.1/` |
| dev | `nubes-dev` | `terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/0.0.1/` |
| test | `nubes-test` | `terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes/0.0.1/` |
| prod | `nubes` | `terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes/nubes/0.0.1/` |
## Предусловия — ПРОВЕРЕНО, всё готово
- Go 1.23.1, docker 29.1.3, `mc`, GPG (`secrets/private_key.asc`, `public_key.asc`).
- Токены API: `secrets/{dev,test,prod}.token` на месте.
- API-эндпоинты доступны (HTTP 403 без токена — ожидаемо, токен передаёт 01).
- `TOOLS/config/<стенд>/operation_timeouts.json` на месте.
- `registry.env`: `REGISTRY_HOSTNAME=tf-registry.containerk8s.services.ngcloud.ru`, `S3_BUCKET=nubes-terraform-registry`.
- `registry.env`: `REGISTRY_HOSTNAME=tf-registry.containerk8s.services.ngcloud.ru`, `S3_BUCKET=terraform-registry`.
## Чего НЕ делать
- НЕ запускать `04_build_and_publish_docs.sh` (документация не нужна сейчас).
+23
View File
@@ -0,0 +1,23 @@
# 10_plans — планы работ
Планы, связанные с версионированием, перегенерацией провайдера и модификаторами.
## Файлы
| Файл | О чём | Статус |
|---|---|---|
| `PLAN_FLASH_reversion_cleanup.md` | Чистка реестра (S3) от легаси-версий + новая схема нумерации: **prod=`1.*`, dev=`2.*`, test=`3.*`**; порядок перегенерации стендов | ✅ Актуально (источник схемы нумерации) |
| `PLAN_modifier_redesign.md` | Редизайн «ресурсов-модификаторов» (`kind: modifier`) — шаги 1..10, `delete_strategy`, `idempotency`, `inverse` | ⛔ **Отменённый путь** (баннер в файле): логику вшивали в универсальный генератор |
| `PLAN_regenerate_providers_0.0.1.md` | Перегенерация всех стендов версией `0.0.1` | ⚠️ **Перекрыт**: `0.0.1` объявлен легаси в `PLAN_FLASH_reversion_cleanup.md` |
## Как читать
- Схема нумерации и порядок заливки — только из `PLAN_FLASH_reversion_cleanup.md`.
- `PLAN_modifier_redesign.md` читать **только как историю**: он описывает заход, от которого отказались
(метки `kind: modifier` в YAML + реестр в `yaml-generator`). Актуальные выводы по модификаторам —
в `../40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
## Не путать
Отмена `PLAN_modifier_redesign.md` **не означает**, что модификаторы не нужны. Нужны **отдельные
tf-ресурсы под `modify`**, но без доменных меток в универсальном YAML — см. хендовер.
+45
View File
@@ -0,0 +1,45 @@
# 20_prompts — промпты для LLM
Промпты, которые отправлялись внешним моделям (Opus / Sol / Sonnet / DeepSeek Flash / GPT‑5.2‑Codex).
Ответы моделей лежат отдельно — в `../30_analysis/` и `../40_chat_summaries/`.
> ⛔ = отменённый («ложный») путь, только история. ✅ = актуально.
## Статус: актуальное
| Файл | О чём | Статус |
|---|---|---|
| `prompt_for_opus_iac_shturval_modify.md` | IaC-развёртывание Штурвала: проблема `modify` и скрытых зависимостей (5 вопросов). **Факты внутри исправлены** (vIPConfigure — replace-семантика, а не накопительная) | ✅ Актуально |
| `prompt_for_opus_modifier_global_architecture.md` | Как сделать модификаторы **НЕ инвазивным дополнением**: YAML — чистая выгрузка API, модификаторы — не ветка генератора | ✅ Актуальное направление (не реализовано) |
| `prompt_for_opus_modifiable_architecture.md` | Простая логика «изменяемости» параметров (CreateOnly vs Modifiable) | ⚠️ Статус не определён |
| `prompt_for_opus_review.md` | Код-ревью + оценка архитектуры, 3 задачи roadmap (в т.ч. `vcOrg modify` — динамическая аллокация IP) | ⚠️ Статус не определён; ответ — `../30_analysis/opus_review_answer.md` |
## Статус: отменённый заход (модификаторы со метками в YAML)
| Файл | О чём |
|---|---|
| `prompt_for_opus_modifier_architecture_full.md` ⛔ | Спроектировать с нуля архитектуру `kind: modifier` |
| `prompt_for_opus_modifier_architecture_q3.md` ⛔ | Уточнения к архитектуре модификаторов (расхождения с кодом) |
| `prompt_for_opus_modifier_null_bug.md` ⛔ | Баг: modify-модификатор сбрасывает create-поля (`needEnableAVI` true→false) |
| `prompt_for_opus_modifier_plan_review.md` ⛔ | Ревью плана редизайна модификаторов |
| `prompt_for_opus_modifiers_review.md` ⛔ | Код-ревью модификаторов в универсальном провайдере |
| `prompt_for_opus_modifier_review_2.md` ⛔ | Ревью `vc_nsxt` / `vc_org` + досылка modify-params через `paramValue` |
| `prompt_for_opus_inverse_architecture.md` ⚠️ | Архитектура inverse-отката модификаторов (`delete_params`, `zero_count`, `off_value`). Модель — от отменённого механизма; факты внутри (count=0, `no-needed`) переиспользуются |
## Промпты по другим темам (не про модификаторы)
| Файл | О чём |
|---|---|
| `prompt_for_opus_bugs.md` | Баг: `FindInstanceByDisplayName` не находит существующий инстанс |
| `prompt_for_opus_duplicate.md` | subresource duplicate/exist + `state_out` |
| `prompt_for_flash_fix_tainted_replace.md` | ТЗ для DeepSeek Flash: убрать create-time проверку существования из `ModifyPlan` |
| `prompt_for_sol_dev_generator_bug.md` | Проверка решения бага Dev-генератора |
| `prompt_for_sonnet_docs_ux.md` | BRIEF: анализ и рекомендации по документации провайдера (UX) |
| `prompt_deepseek_flash.txt` | Роль: технический редактор документации; правила «не менять параметры/типы/ID» |
| `gpt5_5.2_codex_universal_provider_prompt.md` | Задачи по `terra`-провайдеру (Codex) |
## Ответы на эти промпты
- `../30_analysis/opus_review_answer.md` — ответ на `prompt_for_opus_review.md`
- `../30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md` — ответ на `prompt_for_opus_iac_shturval_modify.md`
- `../HISTORY/OPUS/` и `../HISTORY/SONNET/` — исторические ответы по датам
@@ -15,9 +15,9 @@ Constraints:
---
## 2) Файлы для изучения (указать полные пути)
- docs/ARCHITECTURE_NEW.md — архитектурный обзор
- NOTES/30_analysis/ARCHITECTURE_NEW.md — архитектурный обзор
- docs/README.md, docs/index.md — документация / навигация
- docs/howitwasdone.md — история решений
- NOTES/50_process/howitwasdone.md — история решений
- docs/ai_universal_provider_gen.md — процесс генерации провайдера (YAML → Go)
- universal_rebuild/tools/gen/main.go — генератор (основная логика)
- universal_rebuild/internal/resources_gen/* — примеры сгенерированных ресурсов
@@ -0,0 +1,130 @@
# ТЗ для DeepSeek Flash: убрать create-time проверку существования из `ModifyPlan`
Дата: 2026-09-21 | Статус: не сделано | Версия провайдера на момент бага: 2.0.6
## Цель
Починить `terraform destroy` (и любую `tainted`-замену), который падает с
`РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)`.
## Контекст
- Репозиторий: `/home/naeel/TF/tf_provider`
- Провайдер: `terraform-provider-nubes`, Go, `terraform-plugin-framework v1.8.0`
- Ресурсы генерируются шаблоном, **НЕ правятся руками**
- Модуль провайдера живёт в `provider/` (не в корне репозитория)
## Симптом
```
$ terraform destroy
nubes_vc_vdc.vdc: Refreshing state... [id=db2cefc3-...]
nubes_vc_nsxt.edge: Refreshing state... [id=8AAEC14D-...]
│ Error: РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)
│ with nubes_vc_nsxt.edge,
│ on edge.tf line 1, in resource "nubes_vc_nsxt" "edge":
```
То же самое при обычном `terraform plan`.
## Причина (подтверждена фактами)
1. `nubes_vc_nsxt.edge` в state помечен **`tainted`** (следствие прошлой неудачной
apply с `vdc_group_uid`: `Provider returned invalid result object after apply`).
Проверка: `terraform.tfstate` → `instances[].status == "tainted"`.
2. Tainted-ресурс Terraform обязан **заменить** (destroy + create). Это видно в плане:
```
# nubes_vc_nsxt.edge is tainted, so must be replaced
-/+ resource "nubes_vc_nsxt" "edge" {
```
3. `terraform destroy` сначала выполняет **внутренний обычный plan**
(`Context.destroyPlan: calling Context.plan` — видно в `TF_LOG=TRACE`), и уже
на этом шаге планируется замена edge.
4. Create-узел замены вызывает `ModifyPlan` с **prior state = null**, поэтому guard
`if state != nil && !state.ID.IsNull() && ...` пропускается, и доходит до
create-time проверки существования.
5. Проверка находит **живой** инстанс в облаке (старый edge ещё не удалён — удаление
идёт на apply) → `РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)` → plan падает →
`destroy` не начинается.
Ключевое: на уровне `ModifyPlan` **невозможно** отличить «создание нового ресурса»
от «create-узла замены» — у обоих prior state = null. Поэтому проверки существования
в `ModifyPlan` быть не должно в принципе.
Доказательство, что вызов идёт из `ModifyPlan`: `/tmp/nubes_find_debug.log` содержит
`[FIND-DEBUG] PlanExistingResourceDiagnostics entered: serviceId=22 name="fullpipe-edge"`.
Эту строку пишет только Plan-функция; `Create...` в этот лог не пишет.
## Правка
**Один файл:** `TOOLS/resource-generator/internal/templates/instance.go`, шаблон метода `ModifyPlan`.
Удалить целиком блок от строки
```go
if config.ResourceName.IsNull() || config.ResourceName.IsUnknown() {
return
}
```
до строки
```go
resp.Diagnostics.Append(resources_core.PlanExistingResourceDiagnosticsWithParamsAndDomainAndServices(ctx, r.client, {{.ServiceID}}, config.ResourceName.ValueString(), adoptExistingOnCreate, params, desiredDomain, domainServiceIDs, {{.SupportsSuspendDestroy}})...)
```
включительно. Это весь хвост `ModifyPlan` после блока «Missing required attribute»:
`adoptExistingOnCreate`, resolve refSvc, `params`, `desiredDomain`, `domainServiceIDs`
и сам вызов диагностики.
### Что НЕ трогать
- destroy-guard `if req.Plan.Raw.IsNull() { return }` — **оставить**;
- блок create-only проверок (по `state.ID`) — **оставить**;
- блок «Missing required attribute» — **оставить**;
- `Create` — там вызов `CreateExistingResourceDiagnosticsWithDomainAndServices`
**остаётся**: проверка выполняется на apply, уже после удаления старого инстанса.
### Побочный эффект (принять как норму)
Из plan пропадают проверки ref-параметров / domain / существования по имени. Это
штатное поведение Terraform: на apply `Create` резолвит refSvc (с ошибкой) и делает
проверку существования.
## Проверка
```bash
cd /home/naeel/TF/tf_provider && ./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev
```
```bash
TMP=$(mktemp -d) && cp -R provider "$TMP/provider" && find "$TMP/provider/internal/resources_gen" -maxdepth 1 -type f -name '*.go' -delete && cp generated/dev/go/*.go "$TMP/provider/internal/resources_gen/" && (cd "$TMP/provider" && go build ./...) && echo BUILD_OK && rm -rf "$TMP"
```
Затем проверить сгенерированный код:
- `generated/dev/go/22_vc_nsxt_resource.go`: в `ModifyPlan` вызова
`PlanExistingResourceDiagnosticsWithParamsAndDomainAndServices` больше нет;
- в `Create` вызов `CreateExistingResourceDiagnosticsWithDomainAndServices` остался.
Если после удаления какой-то импорт стал неиспользуемым (`fmt`, `resources_core`) —
проверить сборкой. Обычно `Create` сохраняет те же импорты, отдельная правка флага
`NeedsFmtImport` не требуется.
## Что НЕ делать
- **НЕ собирать и НЕ заливать** провайдер — только правка шаблона + генерация + сборка-проверка.
- Не править сгенерированный код руками.
- Не трогать `provider/internal/resources_core/resource_diagnostics_required.go`.
## Критерий готовности
`BUILD_OK` и в сгенерированном edge `ModifyPlan` нет create-time проверки.
## Обходной путь без правок (если надо убить стенд прямо сейчас)
```bash
terraform untaint nubes_vc_nsxt.edge && terraform destroy
```
@@ -0,0 +1,51 @@
# Промпт для Opus: IaC-развёртывание Штурвала, проблема `modify` и скрытых зависимостей
## Правила ответа (жёстко)
1. НЕ лезь в файлы/репозиторий/сеть. Отвечай ТОЛЬКО по материалу ниже.
2. Отвечай КРАТКО, тезисами, по номерам вопросов. Без простыней.
3. Токены/секреты/креды НЕ нужны — если захочешь, не упоминай и не проси.
4. Если для ответа не хватает данных — прямо пиши «неизвестно», не выдумывай.
5. Не предлагай «ручной ЛК / скрипт / пресеты дефолтного окружения» как решение IaC — это уже отклонено (клиенту нужен полноценный IaC).
## Контекст
Terraform-провайдер для Nubes Cloud. Клиенту нужен IaC: один конфиг + `terraform apply` = вся инфраструктура. Цепочка Штурвала:
```
vcOrg -> create
vcVdc -> create
vcNsxt -> create
vcOrg -> modify (аллокация внешних IP)
vcNsxt -> modify (включить SNAT, указать внешний IP из vcOrg)
k8sShturval -> create
```
Операции строго последовательны.
Факты (подтверждены):
- Провайдер генерируется из YAML-спеков. Схема tf-ресурса строится ТОЛЬКО из операции `create`.
- `vIPConfigure` (array-map-fixed, sub: name/count) есть только в `modify` vc_org (id 207); в `create` (136) его нет.
- `ipSpaceName` (string) есть только в `modify` vc_nsxt (id 111); в `create` (10) его нет.
- `vIPConfigure` — **не накопительный, а replace-семантика** (подтверждено `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md`): повторный `modify` с тем же `count` не аккумулирует IP (1→1), работает в обе стороны (вверх/вниз/до 0), `count=0` принимается. Значение задаётся целиком, читается из `state.params`.
- `ipSpaceName` выводится из цепочки `providerVdc -> providerGateway -> ipSpace`, которую пользователь не знает. Сейчас платформа «подкладывает» недостающие параметры при создании пустой орги.
- Допущение платформы: в организации один T0/провайдер-шлюз. Рост числа T0 отложен.
- Платформа в движении: форма ресурсов зависит от новых спеков (ждут, придут сначала в sandbox).
Прецедент (VCD): та же цепочка делается отдельными ресурсами с `depends_on` — `vcd_nsxt_alb_settings` (count + is_active), `vcd_nsxt_alb_edgegateway_service_engine_group` (reserved_virtual_services), `vcd_network_routed_v2`, `vcd_ip_space_custom_quota` (на оргу). Включение/выключение = `count`, inverse = удаление ресурса.
Разница с каноном: у нас нет отдельного API-объекта под модификацию — только операция `modify` над родителем (Read = чтение родителя, Delete = обратный modify, нужна идемпотентность).
## Вопросы
1. `vIPConfigure` уже ведёт себя как replace-состояние (идемпотентно, обе стороны, `count=0` читается из `state.params`). Как это оформить в tf-ресурсе, чтобы Read брал `state.params`, а Delete (inverse) выставлял `count=0` — если отдельного API-объекта нет?
2. Как провайдер должен получать выводимое значение `ipSpaceName` (цепочка providerVdc → providerGateway → ipSpace): data-source, вычисляемое из state родителя, или иное? Где граница «данные vs логика», что хранить в реестре, что выводить из типа/state?
3. Как спроектировать форму ресурсов, чтобы не завязываться на допущение «в организации один T0», и что сломается/что менять, если T0 станет больше одного?
4. Стоит ли ждать новых спеков платформы перед проектированием ресурсов, или форму ресурсов можно зафиксировать уже сейчас так, чтобы она пережила изменение спеков? Что в спеках — блокер, что — нет?
5. Минимально-инвазивный порядок внедрения: что должно прийти от платформы (spec/API) до того, как мы начинаем кодить, а что можем сделать на стороне провайдера уже сейчас?
Отвечай по номерам, кратко.
@@ -0,0 +1,47 @@
# Вопрос: архитектура inverse-отката модификаторов (без чтения файлов)
ЗАПРЕЩЕНО лезть в файлы репозитория. Отвечай только по тексту ниже. Ответ — максимально краткий (тезисы), но исчерпывающий.
## Контекст
Terraform provider для Nubes Cloud. Есть «модификаторы» — отдельные TF-ресурсы, вызывающие операцию `modify` над инстансом по кодам параметров, а не по числовым id. Примеры:
- `vc_org` → модификатор `ip_space`, параметр `vIPConfigure` (array-map-fixed) = `[{"name":"internet-ipv4-v1","count":3}]` — выделение внешних IP.
- `vc_nsxt` → модификатор `network`, параметры `needEnableAVI` (boolean), `ipSpaceName` (string, valueList содержит sentinel `"no-needed"`), `routedNetConfiguration`.
У модификатора есть `delete_strategy`, определяющий что делать при `terraform destroy`:
- `noop_warn` — снять из state, эффект остаётся (предупреждение).
- `error` — запрет удаления (сейчас так на vc_org, из-за чего destroy встаёт).
- `inverse` — при Delete выполнить обратную операцию `modify` с `delete_params` (список `{Code, Value}`).
## Проблема
Хочу, чтобы `destroy` и `apply` были полными и симметричными. При удалении модификатора нужно «откатить» эффект:
1. `needEnableAVI` → `false`.
2. `ipSpaceName` → `"no-needed"`.
3. `vIPConfigure` → `count=0`, имя сохранить (`[{"name":"internet-ipv4-v1","count":0}]`).
Пункты 1-2 — статические константы, текущий механизм `delete_params {Code,Value}` покрывает.
Пункт 3 — динамический: имя берётся из текущего state инстанса, обнуляется только `count`.
Требование: решение должно быть архитектурно чистым и универсальным (привязанным к типам данных из API, `dataType`/`valueList`/`sub_params`), а не хардкодом имён сервисов — чтобы при неглобальных изменениях API перегенерация подхватывала.
## Ключевые факты (уже проверены)
- `count=0` принимается API, несмотря на `minvalue:1`/`integer > 0` в схеме. Идемпотентно.
- `ipSpaceName` sentinel «выключен» = `"no-needed"` (есть в `valueList`).
- `needEnableAVI` — boolean: обратное = `"false"`.
- Типы из API: `needEnableAVI`=`boolean`; `ipSpaceName`=`string`(+`valueList`); `vIPConfigure`=`array-map-fixed` (sub_params: `name`=string, `count`=integer).
## Вопросы (нужны краткие ответы)
1. Как правильно расширить модель delete_params, чтобы поддержать и статичные обратные значения (`false`, `no-needed`), и динамические преобразования (`count→0`)? Оцени вариант «типизированные правила`: `Mode` ∈ {static, zero_count, …}, где static=текущий Value, zero_count=обнулить integer-поле `count` в каждом элементе array-map-fixed, взятом из live state.
2. Универсальнее ли выводить обратные значения ИЗ ТИПА ПАРАМЕТРА (boolean→"false", string+valueList→первый/помеченный sentinel, array-map-fixed→нулевой count в integer-полях), чем задавать их в реестре исключений? Где баланс: что держать в реестре (данные), что выводить из типа (логика)?
3. Нужен ли отдельный маркер «какое поле array-map-fixed обнулять» (сейчас это `count`), или достаточно общего правила «обнулить все integer-поля sub_params»? Риски обоих.
4. Правильный порядок destroy при зависимостях: `edge_net` (SNAT off + ALB off) → `org_ips` (count=0) → `nsxt` → `vdc`. Как Terraform сам выведет порядок из `depends_on`, и где инверсия/откат может конфликтовать с порядком удаления дочерних инстансов?
5. Есть ли подводные камни в самом `inverse`-delete (если дети ещё живы, откат `count=0` на орге может не пройти)? Нужен ли двухфазный подход или достаточно полагаться на порядок?
Формат ответа: пункты пронумерованы под мои вопросы, 1-3 предложения на пункт. Без лишнего.
@@ -0,0 +1,61 @@
# Задача: спроектировать ПРОСТУЮ логику «изменяемости» параметров (CreateOnly vs Modifiable)
## Проблема
Генератор terraform-провайдера строит проверку «Нельзя изменить X» (CreateOnly) на основе
только instance-modify. Из-за этого возникают противоречивые и сломанные ситуации:
`generated/dev/resources_yaml/22_vc_nsxt.yaml`:
- create (id 10), param `needEnableAVI` (id 340) — помечен `is_modifiable: true`;
- instance-modify у `vc_nsxt` НЕТ (modify 111 — это **modifier** `vc_nsxt.network`).
Генератор:
```
ComputeCreateOnly(createParams, instanceModifyParams):
поле считается CreateOnly, если его code нет в instance-modify
```
Следствие: `needEnableAVI` попадает в CreateOnly → генерится жёсткая проверка
«Нельзя изменить need_enable_avi», хотя по YAML параметр `is_modifiable: true`.
Плюс `ConvertParams` вообще **не переносит** `is_modifiable` из ParamSpec в Param —
поле теряется, логика его учесть не может.
## Ключевые файлы (текущая логика)
- `TOOLS/lib/types.go` — `ParamSpec.IsModifiable` (есть, `is_modifiable` сериализуется в YAML)
- `TOOLS/resource-generator/internal/types/types.go` — `Param` (НЕТ поля IsModifiable)
- `TOOLS/resource-generator/internal/loader/loader.go` — `ConvertParams` (не переносит IsModifiable)
- `TOOLS/resource-generator/internal/params/params.go` — `ComputeCreateOnly` (игнорирует is_modifiable и modifier)
- `TOOLS/resource-generator/internal/templates/instance.go` — шаблон, рендерит «Нельзя изменить» из `.CreateOnlyParams`
- YAML: `generated/dev/resources_yaml/22_vc_nsxt.yaml` (modify 111 — `kind: modifier`)
## Существующие понятия операции
В YAML операции бывают видов:
- `kind: instance` (`create` / `modify` / `suspend` / `resume` / `delete`)
- `kind: modifier` (отдельный TF-ресурс, `modify` на родительском инстансе, например `vc_nsxt.network`)
- `kind: subresource`
- `kind: action`
## Цель
Спроектировать **единую, простую и понятную** модель «изменяемости» параметра, чтобы:
1. параметр считался изменяемым, если он изменяем ХОТЯ БЫ через один канал
(instance-modify ИЛИ modifier);
2. «Нельзя изменить» генерировалось ТОЛЬКО для реально create-only параметров;
3. `is_modifiable` из YAML был единственным источником правды (или явно согласован с каналами modify);
4. не было противоречий вида «в YAML is_modifiable:true, а в коде «Нельзя изменить»».
## Вопросы к Opus
1. Какая каноническая модель: вычислять изменяемость по `is_modifiable` (флаг из YAML),
по наличию кода в любом modify (instance + modifier), или по комбинации?
2. Где именно проставлять/вычислять флаг — в yaml-generator (при генерации YAML), или в
resource-generator (при генерации Go)?
3. Как связать modifier-параметры (`vc_nsxt.network`) с parent-инстансом (`vc_nsxt`),
чтобы instance знал, что `needEnableAVI` изменяется через modifier?
4. Минимальный, без legacy-наслоений, набор правил.
Ответ — кратко, с конкретной архитектурой и точками правки (файл + функция).
@@ -0,0 +1,77 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Отменённый заход (`kind: modifier` в YAML + реестр в генераторе). Сохранён как история.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Спроектировать С НУЛЯ архитектуру/логику «ресурсов-модификаторов» (kind: modifier)
## Цель
Перепроектировать модификаторы целиком, чтобы исключить ВСЕ классы багов, не латать по одному.
Нужна единая, полная модель поведения — без догадок и костылей. Перечислить ВСЕ кейсы.
## Что такое модификатор (текущая фактура)
В YAML (генерируется из API) операции бывают:
- `kind: instance` (create/modify/suspend/resume/delete) — обычный CRUD-ресурс;
- `kind: modifier` + `modifier: <name>` — отдельный TF-ресурс, который вызывает `modify`
на родительском инстансе. Сейчас их два: `vc_org.ip_space`, `vc_nsxt.network`.
Реальные примеры:
- `vc_org` → modifier `ip_space` (modify 207), параметр `vIPConfigure` (array-map-fixed);
- `vc_nsxt` → modifier `network` (modify 111), параметры `needEnableAVI`(bool),
`virtualServicesCount`(int>0), `qosProfile`(string), `ipSpaceName`(string),
`routedNetConfiguration`(map-fixed).
## Текущий механизм (что есть — факты, не догадки)
1. Генератор: `TOOLS/resource-generator/internal/templates/modifier.go`
- Create и Update **идентичны**: оба шлют `modify` с полным набором полей.
- `Delete` — **no-op** (комментарий: «no confirmed inverse payload»).
- Схема: `id` computed, `<service>_id` required, поля Optional (или Required если нет default).
2. `resources_core.CompactParams` — выбрасывает пустые строки из payload.
3. `resources_core.BuildActionID(instanceUID, operation, modifierName)` — константный ID,
не привязан к реальной операции (opUid не сохраняется).
4. `core.RunInstanceOperationUniversalByCode` — резолвит code→id через
`GET /instanceOperations/{opUid}?fields=cfsParams` (fallback на `/default/{opId}`);
отправляет переданные params, затем дозаполняет остальные их live-значением
(guard: пропускает параметр, если нет ни ParamValue, ни DefaultValue).
5. `Read` — через `RefreshResourceState`: читает `state_params` инстанса и
перезаписывает input-поля из них.
## Уже выявленные КЛАССЫ багов (все реально случились)
- **A. Сброс create-поля при modify.** modify со сброшенными (null) параметрами
трактуется бэкендом как reset-to-default: `needEnableAVI` стал false после
create=true. Причина: модификатор шлёт только свои поля, `CompactParams` выкидывает
пустые, бэкенд видит «отсутствующий» и сбрасывает.
- **B. Досылка синтетики.** фикс «досылать всё» слал `"0"` для `integer > 0`
(параметр `virtualServicesCount`), API 400 «Invalid format integer > 0».
- **C. Ложное «Нельзя изменить».** `ComputeCreateOnly` считал `needEnableAVI`
CreateOnly (change-forbidden), хотя в YAML `is_modifiable: true` — потому что
генератор не учитывал modifier-канал и терял `IsModifiable`. (Зафиксировано отдельно.)
- **D. No-op Delete оставляет эффект на платформе.** destroy модификатора убирает
ресурс из state, но выделенные IP / включённый ALB остаются на платформе → drift.
- **E. Повторный apply после taint/replace** снова гонит modify — риск повторной
аллокации (для `ip_space`), идемпотентность не гарантирована.
## Вопросы к Опусу (ответить ПОЛНО, по пунктам, с точными местами правки)
1. **Канон «как сравнить и применить».** Должен ли модификатор перед modify
читать текущее состояние и слать ДЕЛЬТУ (только реально изменившиеся поля),
или ПТЦ полный payload? Как детектить drift в Read?
2. **Досылка незаданных полей (паер-заливы A и B).** Какое каноническое правило:
когда досылать live-значение, когда дефолт, когда пропускать? Как не сломать
`integer > 0` и прочие constraints?
3. **Delete/rollback.** Где искать обратный payload? Как правильно поступить, пока
обратный payload НЕ подтверждён API (no-op допустим? явная ошибка? suspend?).
4. **Idempotency + ID.** Как сделать ID модификатора отражающим фактическую операцию
(opUid?) и как предотвратить двойную аллокацию при replace/повторном apply?
5. **Связь с родителем.** Должен ли модификатор использовать `<service>_id` как ссылку
на родителя (depends_on / borrow state), и как читать UUID родителя?
6. **Create vs Update.** Допустимо ли иметь их идентичными, или нужен строго Update-семантик
(нет create, только apply-по-десяти)?
7. **Полный перечень кейсов.** Перечислить ВСЕ edge-кейсы, которые надо покрыть:
create родителя → modifier; remove modifier; replace; partial params; unknown/absent.
Ответ — архитектурный документ (краткий, структурированный), с конкретными файлами
и функциями. НЕ код-ревью, а ПРОЕКТ.
@@ -0,0 +1,68 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Отменённый заход (`kind: modifier` в YAML + реестр в генераторе). Сохранён как история.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Уточнения к архитектуре модификаторов — расхождения с фактическим кодом
Не принимаю предыдущие ответы за истину. Сверка с реальным кодом выявила расхождения.
Прошу пересмотреть/уточнить.
## Факт №1: `OperationSpec` — это алиас `lib.OperationSpec`, не локальный тип
В `TOOLS/resource-generator/internal/types/types.go`:
```go
type OperationSpec = lib.OperationSpec
type ParamSpec = lib.ParamSpec
```
Канонический YAML-контракт лежит в `TOOLS/lib/types.go` (пакет `tf-tools/lib`),
где уже определены `OperationSpec` (Name/ID/Kind/Action/Modifier/Subresource/Man/Params)
и `ParamSpec`.
Ошибка в прошлом ответе: «добавить в types.go:39» — НЕ указано, что это `lib`.
Новые поля `delete_strategy` / `idempotency` / `delete_params` должны быть
в `TOOLS/lib/types.go`, иначе yaml-generator (который тоже импортирует lib)
и resource-generator разойдутся.
Вопрос: подтверждаешь, что новый контракт добавляется в `lib/types.go\` (OperationSpec),
а `resource-generator` получает его через алиас? Или нужно отдельное
resource-generator-специфичное поле (не в lib, а в GenModifier)? Где граница:
что в lib, что локально в GenModifier?
## Факт №2: `normalizeUniversalValueV6` — приватная, живёт в core, принимает core-структуру
Прошлый ответ: «сравнивать desired vs current после normalizeUniversalValueV6».
Но:
- `normalizeUniversalValueV6(val string, param universalCfsParam)` — **приватная** (маленькая буква);
- принимает `universalCfsParam` (структуру пакета `core`);
- сравнение pre-check «desired == current» предполагалось в `resources_core`
(там `RunOperationByCodeWithTimeout`) или в шаблоне модификатора.
Вопрос: ГДЕ правильно делать pre-check и нормализованное сравнение?
- вариант A: в `core` (там доступны и cfsParams, и normalize), экспортировать сравнение;
- вариант B: в `resources_core` — тогда нужен экспортированный компаратор
(`JSONStringsEquivalent` там уже есть), но `universalCfsParam` недоступен;
- вариант C: сравнение только через `JSONStringsEquivalent` по JSON-строкам,
без `normalizeUniversalValueV6`? (но тогда `" 5"` vs `"5"`, `true` vs `1` дадут ложный diff).
Как совместить нормализацию типов (bool→"true", int→"5") с местом, где сравнение
происходит? Конкретный файл+функция.
## Дополнительные сомнения (прошу подтвердить/опровергнуть)
1. **Idempotency pre-check и «полный payload» конфликтуют?** Если desired==current → skip.
Но при этом «полный payload» не шлётся вообще (skip). Это согласуется? Или при
расхождении одного поля всё равно слать полный payload (и это нормализует всё)?
2. **`delete_strategy: inverse` + параметр, у которого НЕЛЬЗЯ обнулить** (напр.
`virtualServicesCount` integer>0): прошлый ответ — «inverse недопустим, fail-fast».
Но что если inverse-стратегия нужна только для ЧАСТИ полей, а не для всех?
Т.е. `delete_params` покрывает `needEnableAVI:false`, а `virtualServicesCount`
просто остаётся как есть. Допустимо ли «частичный inverse» (обратить только
обратимое, остальное не трогать)? Или inverse обязан покрывать все поля?
3. **`noop_warn` (дефолт) — всегда ли безопасен?** Удаление модификатора из state
при оставшемся эффекте на платформе — это drift. Допустимо ли вообще иметь
`noop_warn` как ДЕФОЛТ, или для необратимых (ip_space) правильнее дефолт `error`
(запретить destroy, пока не разберутся)? Что каноничнее?
Ответ — кратко, по пунктам.
@@ -0,0 +1,50 @@
> ✅ **АКТУАЛЬНОЕ НАПРАВЛЕНИЕ (пометка 2026-09-24), НО ЕЩЁ НЕ РЕАЛИЗОВАНО.**
> Требования отсюда действительны: YAML — чистая выгрузка API без доменных меток; модификаторы — НЕ ветка
> универсального генератора. Ответ Opus по нему см. в `NOTES/30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md`.
> Состояние и развилка: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Глобальная архитектура модификаторов: как сделать их НЕ инвазивным дополнением
ЗАПРЕЩЕНО лезть в файлы репозитория. Отвечай только по тексту. Формат: тезисы, кратко, по пунктам моего вопроса. Без лишнего.
## Контекст
Terraform provider для Nubes Cloud. Цепочка кодогенерации:
1. `01_generate_yamls` — идёт по API, по каждому облачному сервису тянет операции и параметры, пишет универсальный YAML (`resources_yaml/<id>_<svc>.yaml`).
2. `02_generate_resources` — по этому YAML генерирует Go-ресурсы провайдера (`<id>_<svc>_resource.go`).
Обычные ресурсы (`nubes_vc_nsxt`, `nubes_vc_vdc` и т.д.) — это операции `create`/`delete`/`suspend`/`resume`/`reconcile` над инстансом. Их apply/destroy давно стабильны и оттестированы.
## Что такое «модификатор» (доменная суть)
Некоторые операции `modify` сервиса — это не «изменить инстанс», а **отложенный дочерний шаг** цепочки, который нельзя мешать с create инстанса:
- `vc_org` → `modify` с параметром `vIPConfigure=[{"name":...,"count":N}]` — выделение внешних IP организации.
- `vc_nsxt` → `modify` с `needEnableAVI`, `ipSpaceName`, `routedNetConfiguration` — настройка ALB/SNAT уже созданного Edge.
Такой `modify` семантически НЕ принадлежит lifecycle самого инстанса: это отдельный TF-ресурс, который должен создаваться/удаляться независимо от `create`/`delete` родителя.
## Проблема (как сделано сейчас — неправильно)
Сейчас «модификаторность» вплетена в универсальную генерацию:
- реестр `serviceSpecificModifiers` зашит в исходник yaml-generator и **помечает** операцию `modify` как `kind: modifier` + пишет в YAML `delete_strategy`, `delete_params` и т.п.
- Это ломает главный принцип: YAML должен быть чистой универсальной выгрузкой из API, а обычные ресурсы — не зависеть ни от какого реестра.
Требования:
1. YAML — универсальная выгрузка ВСЕГО из API, без доменных меток (`kind: modifier`, `delete_strategy`).
2. Ресурсы облачных сервисов НЕ должны зависеть от модификаторов. Если модификаторов нет — поведение идентично прежнему (до их внедрения).
3. Модификаторы — чистое ДОПОЛНЕНИЕ: отдельная сущность, отдельный ресурс, со своей семантикой (inverse-откат при destroy, idempotency), которая НЕ просачивается в базовую генерацию.
4. При полном `destroy` должен быть корректный обратный откат: ALB off, SNAT `no-needed`, IP `count=0` — при этом симметричный `apply` возрождает всё.
## Вопросы (ответь по пунктам)
1. **Правильное место доменной семантики модификатора.** Где её хранить, чтобы она была «данными-наложением», а не веткой в универсальном генераторе? Варианты: (а) отдельный конфиг-файл данных (`modifiers.yaml`), который второй проход накладывает на базовый YAML, порождая ОТДЕЛЬНЫЕ YAML-записи модификаторов, не трогая базовые; (б) отдельный `kind` в самих YAML без доменных меток; (в) иное. Обоснуй.
2. **Разделение «модификатор» vs «обычный modify».** Как архитектурно отделить modify-как-модификатор от modify-инстанса, НЕ меняя универсальную выгрузку? Как гарантировать, что при отсутствии модификаторов обычный modify-поток ресурса вообще не затрагивается?
3. **Как структурировать inverse-откат**, чтобы он был: (а) генерализуемым (по типам: boolean→"false", string+valueList→off_value sentinel, array-map-fixed→zero integer-полей), (б) идемпотентным (не дёргать run, если live уже целевое), (в) не влиял на обычные ресурсы. Нужна ли отдельная модель `delete_rule` у модификатора.
4. **Порядок destroy** при цепочке модификаторов, зависящих от обычных ресурсов и друг от друга (`SNAT-модификатор → IP-модификатор → edge → vdc`). Как выразить зависимость модификатора от ресурса так, чтобы Terraform сам вывел обратный порядок, не завязываясь на хрупкий `depends_on`?
5. **Минимально-инвазивная миграция.** Как перейти от текущего (модификаторы «вросли» в базовую генерацию) к целевой (модификаторы — наложение) без регресса уже стабильных обычных ресурсов? Что трогать НЕЛЬЗЯ.
Ответь кратко, по номерам, 2-4 предложения на пункт.
@@ -0,0 +1,45 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Баг относится к отменённому заходу (`kind: modifier` в YAML + реестр в генераторе).
> Сохранён как история. Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Баг: modify-модификатор сбрасывает create-поля в дефолт (needEnableAVI true→false)
## Симптом
`nubes_vc_nsxt_network` (modifier vc_nsxt.network, modify 111) после create Edge с `needEnableAVI=true`, `virtualServicesCount=3` сбрасывает `needEnableAVI` на платформе обратно в `false`.
## Подтверждено по API (cfsParams операций)
Create Edge (op `0c169353`):
- 340 needEnableAVI = **true**
- 341 virtualServicesCount = **3**
Modify (SNAT-модификатор, op `d14a149e`):
- 368 needEnableAVI = **null**
- 369 virtualServicesCount = **null**
- 856 qosProfile = **null**
- 372 ipSpaceName = internet-ipv4-v1
- 1112 routedNetConfiguration = {...}
Итоговый state.params Edge: `needEnableAVI = false`.
## Гипотеза
Модификатор строится через `resources_core.CompactParams`, который выбрасывает пустые `Optional`-поля. Бэкенд для `modify` трактует **пропущенный/null** параметр как «сбросить в дефолт» (false/0), а не «оставить как есть». Итог: modify с частичным payload затирает create-поля.
## Файлы
- `provider/internal/core/client.go` — `RunInstanceOperationUniversalByCode` (отправка params), `normalizeUniversalValueV6`
- `provider/internal/resources_core/crud.go` — `RunOperationByCodeWithTimeout`, `CompactParams`
- генератор: `TOOLS/resource-generator/internal/templates/modifier.go`, `internal/writers/writers.go` (WriteModifierResource)
- сгенерированное: `generated/dev/go/22_vc_nsxt_network_modifier.go`
- YAML: `generated/dev/resources_yaml/22_vc_nsxt.yaml` (modify 111, поля is_modifiable)
## Задание
Определить каноническое поведение:
1. Должен ли modify слать **все** параметры операции (полный payload, включая необязательные с их текущими значениями), или допустимо слать только переданные?
2. Где правильнее чинить: в генераторе (шаблоне modifier), в `CompactParams`, или в `RunInstanceOperationUniversalByCode` (досылать дефолты/текущие значения незаданных полей)?
3. Есть ли риск, что «досылать дефолты» сломает другие модификаторы (напр. vc_org.ip_space)?
Ответ кратко, тезисно, с указанием конкретной строки/места фикса.
@@ -0,0 +1,43 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Ревью плана отменённого захода (`kind: modifier` в YAML + реестр в генераторе).
> Сохранён как история. Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Ревью плана реализации: редизайн модификаторов
Прошу отревьюить план `PLAN_modifier_redesign.md` (10 шагов). Это проект к реализации,
не код. Вызовись: найди дыры, пропущенные кейсы, ошибки в порядке шагов, нестыковки.
## Контекст решения (уже согласовано, НЕ пересматривать)
- Модификатор = декларативная проекция полей родителя, единый `reconcile()` (Create≡Update).
- Полный payload (не дельта), досылка: задан→значение, иначе live→default→skip.
- `delete_strategy`: noop_warn | inverse | error (дефолт noop_warn), `idempotency`: none | check_before_run.
- Pre-check `desired==current` в `core` (не в resources_core, не в шаблоне), по живому `state_params`.
- `is_modifiable` — единственный сигнал изменяемости (фикс CreateOnly уже есть).
## Ключевые файлы-факты (сверены с кодом)
- `TOOLS/lib/types.go` — `OperationSpec`/`ParamSpec` (алиасы в обоих генераторах).
- `TOOLS/yaml-generator/main.go` — `serviceSpecificModifiers` (реестр исключений, источник канона).
- `TOOLS/resource-generator/internal/loader/loader.go` — ветка `kind==modifier`, `ValidateSpec`.
- `TOOLS/resource-generator/internal/templates/modifier.go` — шаблон.
- `provider/internal/resources_core/crud.go` — `RunOperationByCodeWithTimeout`.
- `provider/internal/resources_core/json_normalize.go` — `JSONStringsEquivalent` (импорт в core = цикл).
- `provider/internal/core/operation_run_bycode.go` — клиентский запуск.
## Вопросы к ревью (ответить кратко, по пунктам)
1. Порядок шагов 1–10 корректен? Где есть скрытая зависимость, которую я пропустил?
2. Шаг 5 (вынос JSON-эквивалентности в `core/jsonutil`) — правильный путь снять цикл
импорта, или есть чище (напр. оставить `JSONStringsEquivalent` в resources_core и
передавать нормализованные строки в core уже готовыми)?
3. Шаг 6 — сигнатура `modifierDesiredEqualsCurrent(desired map[string]string, cfsParams []universalCfsParam) bool`
корректна? Хватает ли данных для сравнения всех типов (bool/int/string/map-fixed/array-map-fixed)?
4. Шаг 4.4 Delete=inverse — как именно слать modify: `delete_params` + досылка live остальных
(полный payload) — это правильно, или есть подводный камень?
5. Шаг 8 — расширение реестра `serviceSpecificModifiers` до структуры: верный источник?
Или `delete_strategy`/`idempotency` правильнее держать отдельным реестром (не трогая тип map)?
6. Пропущен ли какой-то кейс из 16 (13 + taint/replace/partial/unknown)?
7. Есть ли риск сломать instance-ресурсы (не модификаторы) любым из шагов 1–8?
Ответ — тезисно, с указанием конкретного шага и что в нём поправить.
@@ -0,0 +1,84 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Ревью реализации отменённого захода (`kind: modifier` в YAML + реестр в генераторе,
> досылка modify-params через `paramValue`). Сохранён как история.
> Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Ревью: модификаторы vc_nsxt / vc_org + досылка modify-params (Terraform Provider Nubes)
Ты — ревьюер. Ничего не правь. Прочитай и выдай:
1) подтверждение/опровержение каждого утверждения ниже;
2) список багов/рисков, которые я НЕ заметил;
3) список проблем, которые я заметил ошибочно (ложные тревоги);
4) чёткую рекомендацию по каждому корректному фиксу (минимальную, без scope creep).
## Контекст системы
Go Terraform provider `terraform-provider-nubes` (plugin-framework), сервис Nubes Cloud.
Генератор ресурсов: `TOOLS/resource-generator` (шаблон `instance.go`, `modifier.go`).
Ядро: `provider/internal/core/` (HTTP + операции), `provider/internal/resources_core/` (обёртки).
Сервисы, о которых речь:
- `vc_nsxt` (serviceId 22). Операции: create (10), delete (25), **modify (111, kind: modifier, modifier: network)**, reconcile. У resource НЕТ instance-modify.
- `vc_org` (serviceId 19). Модификатор `ip_space` (modify 207).
Модификатор = отдельный TF resource (`nubes_vc_nsxt_network`, `nubes_vc_org_ip_space`), который вызывает операцию `modify` с параметрами.
## Цель (FullPipe) — что должно работать
1. Создать VDC (vc_vdc).
2. Создать Edge (vc_nsxt) с ALB (`needEnableAVI=true`, `virtualServicesCount=3`).
3. Выделить IP организации (vc_org → modifier ip_space, `vIPConfigure`).
4. Применить SNAT для Edge (vc_nsxt → modifier network, `ipSpaceName` + `routedNetConfiguration`).
## Что УЖЕ сделано (факты, проверь корректность)
### Факт 1. `resource "nubes_vc_nsxt"` Update — no-op
Шаблон `instance.go` генерирует `hasServiceParamChanges := false`, а цикл по `.ModifyParams` пуст (у vc_nsxt нет instance-modify). Поэтому `Update` всегда уходит в `if !hasServiceParamChanges { ...; return }` и НЕ вызывает `modify` (111). Изменение ALB/VS/qos через resource невозможно. Изменения Edge идут ТОЛЬКО через модификатор `nubes_vc_nsxt_network`.
### Факт 2. Досылка незаданных modify-params (мой свежий фикс, коммиты a011358)
Раньше незаданные params операции `modify` досылались значением `paramValue` из `GET /instanceOperations/{opUid}?fields=cfsParams`. Это ОШИБОЧНО: `paramValue` — дефолт ФОРМЫ операции, а не состояние инстанса. Для `needEnableAVI` там `"false"`, хотя live-значение инстанса `true` (подтверждается HAR/edge_.har и HAR/ipSpace0.har). Из-за этого каждый `modify` через модификатор сбрасывал ALB в false.
Фикс: в `operation_cfs.go` добавлены `instanceLiveParams()` (читает live из `GET /instances/{uid}` → `state.params`) и `lookupLiveParam(live, cfsParam)`. В `runInstanceOperationByCode` и `RunInstanceOperationUniversalWithDefaults` приоритет теперь: **live state.params → paramValue → defaultValue**.
### Факт 3. `ShouldRemoveFromState` (коммит 94c4c44)
Раньше вызывал валидирующий `GetInstanceState`, который на статусе `deleted` кидал `instanceDeletedError` — и `Read` модификатора падал с "экземпляр … удалён" вместо тихого удаления из state. Переписан на `GetInstanceStateRaw` + различение 404/deleted (remove=true) vs сеть/5xx/403 (нужно `false, err`).
## ОШИБКА, которую наблюдаю СЕЙЧАС (главное)
`terraform apply` падает:
```
Error: Provider returned invalid result object after apply
After the apply operation, the provider still indicated an unknown value for
nubes_vc_nsxt.edge.qos_profile. All values must be known after apply...
```
`qos_profile` у resource `nubes_vc_nsxt` = `Optional+Computed` БЕЗ Default (`ShouldBeOptionalComputed` → true, потому что param qosProfile: not required, RefSvcId=0, Default=""). В конфиге не задаётся → в плане unknown. А `Update` (`hasServiceParamChanges=false` → ранний return) копирует только `State*`/`Vault*` outputs, но НЕ вызывает `RefreshResourceState`, поэтому `qos_profile` остаётся unknown.
## МОИ ДИАГНОЗЫ (проверь каждый, а не только текущий)
### Диагноз A (текущая ошибка)
Ранний return в `Update` (шаблон instance.go) не схлопывает unknown→null read-back-computed поля. Нужно в ветке `!hasServiceParamChanges` вызывать тот же `RefreshResourceState`, а не копировать `State*`/`Vault*` вручную.
### Диагноз B (вылезет после A)
Тот же ранний return оставляет unknown для `need_enable_avi` и `virtual_services_count`, если их убрать из `edge.tf` (а их и должны убрать, раз ALB перенесён в модификатор). Один корень с A.
### Диагноз C (дублирование конфига — НЕ починен)
`edge.tf` ДО СИХ ПОР задаёт `need_enable_avi` и `virtual_services_count` (create), а `edge_network.tf` — те же значения (modifier). Это двойное задание одного и того же → возможен дрейф. Нужно определить: где канонически задавать ALB?
### Диагноз D (ловушка destroy/delete)
Модификатор имеет `delete_strategy: inverse`, override `needEnableAVI="false"`. При `terraform destroy` ALB выключится. Повторный `apply` через `resource "nubes_vc_nsxt"` (no-op Update) НЕ включит обратно, а включит только модификатор второй apply-волной. Нужно проверить порядок зависимостей.
### Диагноз E
`FetchInstanceOutputs` глотает любую API-ошибку (5xx/404) и возвращает пустые outputs без diagnostic → молчаливый дрейф. Нужен warning.
## Конкретные вопросы
1. Подтверди/опровергни Диагноз A как корень текущей ошибки.
2. Есть ли проблема в моём фиксе досылки (Факт 2)? В частности:
- верно ли, что `state.params` — единственный достоверный источник live?
- не сломает ли `lookupLiveParam` (по Code/SvcOperationCfsParam/Name/Label) какие-то кейсы, где имя в state.params отличается регистром/форматом от этих ключей?
- не создаёт ли `instanceLiveParams` лишний сетевой вызов на каждый modify (перф)?
3. Верна ли трактовка Факт 1 (resource Update — no-op)? Или правильнее ДОБАВИТЬ instance-modify в генератор?
4. Какое каноническое место для `need_enable_avi`/`virtual_services_count`/`qos_profile`: create (edge.tf) или modifier (edge_network.tf)? Что делать с текущим дублированием?
5. Что ещё я упустил в цепочке create→modify→read→destroy?
@@ -0,0 +1,33 @@
> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.**
> Код-ревью отменённого захода (`kind: modifier` в YAML + реестр в генераторе).
> Сохранён как история. Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Код-ревью: модификаторы (kind: modifier) в универсальном провайдере
## Контекст
terraform-provider-nubes (universal). Операции с `kind: modifier` генерируются как отдельные TF-ресурсы и запускают операцию `modify`, передавая параметры **по коду** (`vIPConfigure`, `needEnableAVI`...), а не по числовому id.
Актуальные модификаторы:
- `nubes_vc_org_ip_space` (vc_org.ip_space, modify 207) — выделение внешних IP (`vIPConfigure`).
- `nubes_vc_nsxt_network` (vc_nsxt.network, modify 111) — сеть/SNAT Edge.
## Ключевые файлы
- генератор: `TOOLS/resource-generator/internal/templates/modifier.go`, `internal/loader/loader.go` (LoadSpecs → GenModifier), `internal/writers/writers.go` (WriteModifierResource)
- рантайм: `provider/internal/resources_core/crud.go` (RunOperationByCodeWithTimeout)
- клиент: `provider/internal/core/client.go` (RunInstanceOperationUniversalByCode)
- сгенерированное: `generated/dev/go/19_vc_org_ip_space_modifier.go`, `22_vc_nsxt_network_modifier.go`, `registry.go`
## Известная проблема (уже диагностирована — НЕ ревьюить)
`GET /instanceOperations/{opUid}?fields=cfsParams` падает 500 (`getResourceRealmConfig` Struct→string) на проблемных инстансах. Fallback на `/instanceOperations/default/{opId}` планируется отдельно.
## Задание — короткий код-ревью
1. Корректность жизненного цикла modifier-ресурса: Create/Update/Read/Delete, идемпотентность, refresh из API.
2. Реального Delete нет (destroy не откатывает операцию) — это ожидаемо? Подводные камни при повторном apply.
3. Риски передачи параметров по коду (code → id) в `RunInstanceOperationUniversalByCode`.
4. ТОП-3 самых критичных замечания именно по модификаторам.
Ответ — кратко, тезисно, без кода-простыней.
@@ -0,0 +1,41 @@
# Промпт для Opus 4.8: код-ревью и оценка архитектуры (3 задачи roadmap)
Дата: 2026-09-22 | Статус: для отправки
```text
Роль: ревьюер архитектуры Go-провайдера Terraform.
ПРАВИЛА:
- Файлы НЕ открывай, в репозиторий не лезь — отвечай только по контексту ниже.
- Анализируй ТОЛЬКО 3 указанные задачи, не весь проект.
- Ответ максимально сжатый: только выводы/риски/рекомендации. Без вводных, без «рассмотрим», без повторов. Списки или таблица. Риск помечай 🔴/🟡/🟢.
- Если для ответа не хватает факта — пиши «НЕДОСТАТОЧНО ДАННЫХ: …», не выдумывай.
КОНТЕКСТ (достаточен, файлы не нужны):
- terraform-provider-nubes, Go, terraform-plugin-framework v1.8.0. Ресурсы ГЕНЕРИРУЮТСЯ из YAML-спек сервисов (не рукописные).
- Один сервис → один ресурс инстанса nubes_<service> (CRUD). В YAML: operations (create/modify/suspend/…), params с type (bool/int64/string/map-fixed/array-map-fixed), required, default, is_modifiable, ref_svc_id.
- Schema: param → Required (если required, без default, не refSvc); Optional; Computed+Default (если default); Optional+Computed (если параметр читается обратно из state_params инстанса, или это JSON).
- Create: резолвит refSvc-параметры (принимают display name ИЛИ UUID → uid в API), вызывает create-op, затем читает state обратно.
- Update: если изменились modify-параметры → вызывает modify-op с ними. ModifyPlan запрещает менять create-only атрибуты (ошибка).
- Read: читает state_params инстанса обратно в input-поля (drift), и выставляет computed-мапы: state_params, state_out, state_params_flat, state_out_flat + vault_*.
- Delete: suspend / delete / state_only — в зависимости от наличия suspend-op у сервиса.
- Межресурсные зависимости: пользователь в .tf ссылается на атрибуты других ресурсов (напр. nubes_vc_vdc.vdc.id). ref_svc_id валидирует значение по инстансам целевого сервиса и резолвит в uid.
- Data sources НЕ генерируются (только ресурсы).
- map-fixed → SingleNestedAttribute (HCL: `x = { … }`); array-map-fixed → StringAttribute (JSON-строка).
- Сервисы: vc_org (19), vc_vdc (21), vc_nsxt (22). Спека k8s_shturval уже есть.
УЖЕ СДЕЛАНО: vcVdc create, vcNsxt create — работают (v2.0.8).
АНАЛИЗИРОВАТЬ (только это):
1. vcOrg modify — аллокация внешних IP в организацию ПО МЕРЕ НЕОБХОДИМОСТИ (динамически, число заранее не фиксировано).
2. vcNsxt modify — включить SNAT и указать внешний IP, взятый из уже аллоцированного пула vcOrg.
3. k8sShturval create.
ВОПРОСЫ (ответь по пунктам, кратко):
1. Покрывает ли текущая модель эти 3 задачи, или для какой-то нужна новая абстракция (data source / action / subresource)? По каждой задаче — вердикт.
2. vcOrg IP-аллокация: как моделировать пул внешних IP, растущий по мере необходимости — (а) атрибут-массив на nubes_vc_org, (б) отдельный ресурс/подресурс на каждый IP? Что лучше согласуется с текущей архитектурой и почему.
3. vcNsxt: как передать «внешний IP из vcOrg»? Сравни: (а) refSvc-параметр, (б) computed-атрибут vcOrg + ссылка nubes_vc_org.<name>.<attr>, (в) data source. Учти ограничение: refSvc ссылается на инстанс/uid, но не на конкретный элемент списка.
4. Риски текущего кода именно для этих потоков: update/modify с массивными параметрами; create-only guard; read-back; порядок плана между зависимыми ресурсами.
5. k8sShturval create: что критично проверить (refSvc к vdc/org, долгий create, типы параметров)?
6. Итог: 3–5 конкретных рекомендаций по приоритету — что добавить/изменить в генераторе или ресурсах.
```
@@ -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`.
@@ -280,7 +280,7 @@ CSS скрывает обе боковые панели mkdocs:
2. **TOOLS/docs-generator/internal/writers/writers.go** — ВСЯ генерация .md (~1000 строк, ключевой файл)
3. **TOOLS/docs-generator/main.go** — CLI, флаги, оркестрация
4. **TOOLS/scripts/05_generate_docs_llm.py** — LLM-обогащение, SYSTEM_PROMPT
5. **docs/LLM_DOCS_GENERATION.md** — архитектурная документация
5. **NOTES/50_process/LLM_DOCS_GENERATION.md** — архитектурная документация
6. **docs/30_registry/assets/extra.css** — CSS-стили
7. **docs/30_registry/javascripts/fix-slash.js** — JS (trailing slash fix)
@@ -43,6 +43,25 @@
- `universal_rebuild/tools/gen/main.go`
- Читает YAML и генерирует ресурсы + `registry.go`.
### 1.6 Отдельные modifier-ресурсы
Для parent-level операций `modify`, которые должны выполняться отдельным шагом Terraform-цепочки, используется `kind: modifier`.
Пример:
```yaml
- name: modify
kind: modifier
action: modify
modifier: ip_space
params: []
```
Такой блок не попадает в обычный instance CRUD. Go-генератор создаёт отдельный ресурс с именем `nubes_<service>_<modifier>`. Ресурс принимает ID родительского инстанса и параметры операции, выполняет parent `modify` при Create/Update и читает актуальные значения из `state_params` при Read.
Для `vcOrg` используется modifier `ip_space` с параметром `vIPConfigure`; для `vcNsxt` используется modifier `network` с параметрами операции сетевой настройки. Nested API-параметры modifier-ресурсов передаются как JSON-строки, поэтому их Terraform-значения должны быть валидным JSON.
Удаление modifier пока является no-op: подтверждённого обратного payload для отмены выделенных IP или SNAT нет. Операции удаления родительского сервиса не являются rollback и намеренно не вызываются.
---
## 2) Как получить параметры сервиса (без instanceUid)
@@ -0,0 +1,63 @@
# Отчет о проблеме: Ошибка 500 при получении cfsParams для vc_vdc и план исправления
## 1. Проблема
При создании виртуального дата-центра `nubes_vc_vdc` (сервис 21 `vc_vdc`, операция создания 9) Terraform завершается с ошибкой клиента:
```text
не удалось получить детали операции: ошибка API 500: Invalid call of the function [getResourceRealmConfig], first Argument [resourceRealm] is of invalid type, Cannot cast Object type [Struct] to a value of type [string]: the function is located at [/app/api/v1/resources/instance_operation_cfs_param.cfc]
```
## 2. Анализ причины
1. **Место падения в провайдере**: `provider/internal/core/client.go` (строка 272 в `createInstanceWithContext`):
```go
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
if err != nil {
return "", fmt.Errorf("не удалось получить детали операции: %w", err)
}
```
2. **Поведение бэкенда Nubes (Lucee/ColdFusion)**:
- В файле `/app/api/v1/resources/instance_operation_cfs_param.cfc` при обработке запроса `?fields=cfsParams` для операции 9 вызывается функция `getResourceRealmConfig(resourceRealm)`.
- Для сервиса `vc_vdc` поле `resourceRealm` в БД DEV-окружения хранится как комплексный объект (`Struct`), а не скалярная строка (`string`).
- При попытке приведения типа `Struct -> string` бэкенд падает с HTTP 500.
3. **Сравнение с веб-интерфейсом (HAR/vdc.har)**:
- В официальном веб-интерфейсе ЛК запрос `GET /instanceOperations/{opUid}?fields=cfsParams` **вообще не выполняется**.
- Браузер выполняет строго следующий флоу:
1. `POST /instanceOperations` -> получает `instanceOperationUid`
2. `POST /instanceOperationCfsParams` -> отправляет каждое значение CFS-параметра
3. `GET /instanceOperations/{opUid}/validate-cfs` -> валидация бэкендом
4. `POST /instanceOperations/{opUid}/run` -> запуск операции в оркестраторе
5. Polling `GET /instanceOperations/{opUid}` -> ожидание статуса завершения
4. **Зачем провайдер делает шаг 2**:
- Шаг 2 в провайдере использовался исключительно для вызова `resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)` — чтобы узнать `refSvcId` параметров и попробовать отрезолвить имена в UUID.
- Для ресурса `nubes_vc_vdc` параметр `organization_uid` (CFS param 30, refSvc 19) **уже гарантированно отрезолвлен в UUID** до создания операции (на этапе `ModifyPlan` и в начале `Create`).
- Остальные параметры `vc_vdc` (провайдер сети, профиль Provider VDC, CPU, RAM, резервирование, storage_config) являются скалярами/числами/JSON-строками и не содержат `refSvcId`.
- Соответственно, данные запроса `?fields=cfsParams` для `vc_vdc` фактически не требуются.
## 3. План изменений в провайдере (что будем менять)
### Целевой файл: `provider/internal/core/client.go`
В функции `createInstanceWithContext` (и при необходимости в `runInstanceOperationUniversalByCodeWithTimeout` / `updateResourceWithTimeout`) заменяется жёсткое падение на условный graceful fallback:
```go
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
if err != nil {
// Проверяем, есть ли среди переданных строковых параметров не-UUID значения,
// требующие резолвинга через refSvcId.
// Если все строковые параметры уже UUID или числа/литералы — логируем предупреждение и продолжаем.
c.logWarn(ctx, "не удалось получить cfsParams для операции %s (%v), продолжаем отправку параметров", opUid, err)
} else {
var opDetails universalOpResponse
if err := json.Unmarshal(opDetailsResp, &opDetails); err == nil {
params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)
if err != nil {
return "", err
}
}
}
```
### Критерии безопасности:
1. Fallback условный: если бэкенд упал, но параметры уже валидны / являются UUID — выполнение продолжается.
2. Логируется явный warning с идентификатором операции `opUid` и телом ошибки.
3. Валидация значений параметров не теряется — её по-прежнему выполняет бэкенд на этапе `GET /validate-cfs`.
@@ -0,0 +1,217 @@
# Forensic Analysis: API Model to Ordinary YAML
## Цель
Исследовать существующую цепочку:
```text
API model
↓
ordinary generator
↓
API YAML
```
Цель этапа — получить подтверждённую картину движения данных и установить, что сохраняется, преобразуется или теряется до формирования API YAML.
Этот документ предназначен для передачи Opus перед анализом.
## Строгие ограничения
На этом этапе запрещено:
- изменять файлы;
- писать код;
- менять ordinary generator;
- проектировать `ModifierSpec`;
- проектировать `modifiers.yaml`;
- проектировать `delete_rule`;
- проектировать output layout или orchestration;
- обсуждать inverse и dependency ordering;
- придумывать API identifiers;
- придумывать `parameter_path`;
- придумывать HTTP method или payload structure;
- считать API YAML полным источником данных без доказательства.
Если факт невозможно установить, его нужно обозначить как `unknown` или как требующий проверки фактическим API. Нельзя закрывать неизвестность архитектурным предположением.
## Что исследовать
### 1. Исходная API-модель
Установить:
- где находится canonical API model;
- в каком формате она представлена;
- как представлены services и operations;
- как представлены параметры;
- как представлены nested objects и arrays;
- как представлены типы параметров;
- существует ли стабильный operation ID;
- существует ли стабильный parameter ID;
- какие данные доступны до запуска ordinary generator.
### 2. Ordinary generator
Установить:
- какой input получает generator;
- где он читает API-модель;
- какие внутренние структуры строит;
- какие преобразования выполняет;
- какие поля нормализует или переименовывает;
- какие поля вычисляет;
- какие поля отбрасывает;
- где формируется API YAML;
- где именно могут происходить потери данных.
Обязательно различать:
```text
данные отсутствуют уже в API
```
и:
```text
данные присутствуют в API, но теряются ordinary generator
```
### 3. API YAML
Установить:
- какие поля сохраняются;
- какие поля представлены иначе, чем в API-модели;
- сохраняются ли nested objects и arrays;
- сохраняются ли типы;
- сохраняются ли operation identity и parameter identity;
- сохраняются ли HTTP method и path, если они есть в исходной модели;
- какие данные доступны будущему modifier layer;
- какие данные потенциально доступны только в исходной API-модели.
## Обязательные трассировки
### `vc_org`
Отдельно проследить `vIPConfigure` и `count`:
```text
API model
→ generator input
→ internal generator representation
→ generator transformation
→ API YAML
```
Для каждого этапа указать:
- присутствует ли `vIPConfigure`;
- присутствует ли `count`;
- в каком типе они представлены;
- в какой структуре находятся;
- изменяются ли их имена или типы;
- теряются ли они;
- если теряются, в какой точке.
Не считать заранее известной структуру `vIPConfigure` или `count`.
### `vc_nsxt`
Отдельно проследить `ipSpaceName` и связанные параметры по той же цепочке:
```text
API model
→ generator input
→ internal generator representation
→ generator transformation
→ API YAML
```
Установить:
- где появляется `ipSpaceName`;
- к какой operation или структуре он относится;
- в каком типе представлен;
- является ли обычным полем, nested field или частью массива;
- какие связанные параметры находятся рядом;
- сохраняется ли он в API YAML;
- изменяются ли его значение или тип;
- теряются ли связанные поля.
## Требования к доказательности
Каждый вывод разделять на:
- **Подтверждённый факт** — непосредственно виден из кода, структуры данных, фактического API input, generator input или API YAML.
- **Неизвестное** — информация отсутствует или неоднозначна.
- **Предположение** — не использовать как основание для архитектуры; только явно перечислять как неподтверждённое.
Для каждого важного вывода указывать конкретное основание: файл, функцию, структуру, входной документ или фрагмент YAML/JSON. Если точное место невозможно назвать, это указать как ограничение анализа.
## Формат итогового отчёта
Отчёт должен содержать только следующие разделы:
1. **Подтверждённые факты**
2. **Исходная API-модель**
3. **Как ordinary generator читает модель**
4. **Как формируется API YAML**
5. **Цепочка данных для `vc_org.vIPConfigure.count`**
6. **Цепочка данных для `vc_nsxt.ipSpaceName` и связанных параметров**
7. **Что сохраняется**
8. **Что преобразуется или нормализуется**
9. **Что теряется**
10. **Где происходят потери**
11. **Какие данные доступны в API YAML**
12. **Какие данные доступны только в API-модели**
13. **Неизвестные места**
14. **Что необходимо проверить фактическим API**
## Критерий завершения
Forensic analysis завершён только тогда, когда для каждого потенциально необходимого modifier-элемента можно проследить происхождение:
```text
API model
→ generator input
→ internal representation
→ transformation
→ API YAML
```
без неизвестных промежуточных преобразований.
Для `vc_org` и `vc_nsxt` должны быть подтверждены:
```text
operation identity
parameter identity
parameter structure
parameter type
API model representation
generator representation
YAML representation
transformation
loss or absence
```
Если на существенном этапе остаётся `???`, исследование не завершено. Этот пункт нужно зафиксировать как `unknown`, а не проектировать решение.
## Главный принцип
Сначала:
```text
исследовать
↓
зафиксировать факты
↓
зафиксировать неизвестное
↓
отличить отсутствие данных от потери данных
```
Только после отдельного согласования forensic report можно переходить к проектированию `ModifierSpec`, `modifiers.yaml`, `delete_rule`, modifier generator, output boundary и orchestration.
На текущем этапе никаких решений по этим компонентам принимать нельзя.
@@ -0,0 +1,234 @@
# Forensic Analysis: API Model to Ordinary YAML
**Анализ от Gemini 3.8 flash.**
Исследование проведено строго в границах требований [FORENSIC_ANALYSIS_BRIEF_2026-09-23.md](FORENSIC_ANALYSIS_BRIEF_2026-09-23.md).
---
## 1. Подтверждённые факты
- **Цепочка генерации YAML:** Инструмент `yaml-generator` ([TOOLS/yaml-generator/main.go](../TOOLS/yaml-generator/main.go)) опрашивает live HTTP REST Gateway API Nubes по сети и сериализует результат в YAML-файлы спецификаций (`generated/{stand}/resources_yaml/{service_id}_{name}.yaml`), используя структуры контракта [TOOLS/lib/types.go](../TOOLS/lib/types.go).
- **Спецификации сервисов в репозитории:** Канонические сгенерированные спеки сервисов физически размещены в [generated/dev/resources_yaml/](../generated/dev/resources_yaml/). В частности, [19_vc_org.yaml](../generated/dev/resources_yaml/19_vc_org.yaml) и [22_vc_nsxt.yaml](../generated/dev/resources_yaml/22_vc_nsxt.yaml).
- **В `vc_org.yaml`:**
- Операция `modify` имеет числовой ID `207`, `kind: instance`, `action: modify`.
- Параметр `vIPConfigure` присутствует как параметр операции `modify` с числовым ID `662`, типом `data_type: array-map-fixed`, `required: true`.
- Поле `count` присутствует внутри `sub_params` параметра `vIPConfigure` с числовым ID `40`, `data_type: integer > 0`, `required: true`, `is_modifiable: false`.
- Поле `name` присутствует внутри `sub_params` параметра `vIPConfigure` с числовым ID `39`, `data_type: string`, `required: true`, `is_modifiable: false`.
- **В `vc_nsxt.yaml`:**
- Операция `modify` имеет числовой ID `111`, `kind: instance`, `action: modify`.
- Параметр `ipSpaceName` присутствует в операции `modify` с числовым ID `372`, `data_type: string`, `required: false`, `sort: 50`.
- С ним рядом в операции `modify` присутствуют:
- `needEnableAVI` (ID `368`, `data_type: boolean`, `required: false`, `value_list: ["false", "true"]`);
- `virtualServicesCount` (ID `369`, `data_type: integer > 0`, `required: false`, `minvalue: 1`, `maxvalue: 4`);
- `qosProfile` (ID `856`, `data_type: string`, `required: false`);
- `routedNetConfiguration` (ID `1112`, `data_type: map-fixed`, `required: true`, с вложенными подполями `ipAddrPool`, `mainDns`, `secondDns`).
- **Генератор ресурсов (Ordinary Resource Generator):**
- Расположен в [TOOLS/resource-generator/main.go](../TOOLS/resource-generator/main.go).
- При `op.Kind == "instance"` (что установлено для `modify` в [generated/dev/resources_yaml/19_vc_org.yaml](../generated/dev/resources_yaml/19_vc_org.yaml) и [generated/dev/resources_yaml/22_vc_nsxt.yaml](../generated/dev/resources_yaml/22_vc_nsxt.yaml)) генератор объединяет параметры `create` и `modify` в схему одного общего ресурса `nubes_vc_org` / `nubes_vc_nsxt`.
- Параметры, отсутствующие в операции `create`, но присутствующие в операции `modify` (как `vIPConfigure` в `vc_org`), не включаются в жизненный цикл `create`, а при отсутствии отдельной разметки `kind: modifier` в YAML генератор не создаёт под них отдельного ресурса модификатора.
---
## 2. Исходная API-модель
- **Источник и формат:** Модель получается HTTP-клиентом ([TOOLS/yaml-generator/internal/client/client.go](../TOOLS/yaml-generator/internal/client/client.go#L44-L62)) по протоколу HTTP GET в формате JSON.
- **Эндпоинты API:**
- Метаданные сервиса и список операций: `GET /services/{svcId}` (возвращает `types.ServiceResponse`).
- Метаданные конкретной операции: `GET /instanceOperations/default/{svcOperationId}` (возвращает `types.ServiceOperationResponse`).
- **Идентификаторы операций и параметров:**
- Операция идентифицируется стабильным числовым `svcOperationId` (int) и строковым именем `operation` (например, `"modify"`, `"create"`).
- Параметры идентифицируются стабильным числовым `svcOperationCfsParamId` (int) и строковым кодом `svcOperationCfsParam` (например, `"vIPConfigure"`, `"ipSpaceName"`).
- **Вложенные объекты и массивы (`dataDescriptor`):**
- В ответе эндпоинта `/instanceOperations/default/{id}` сложная структура передаётся в поле `dataDescriptor: map[string]CfsSubParam`.
- Каждое подполе имеет свой числовой `svcOperationCfsSubparamId`, строковый ключ (код подполя), `dataType`, `isRequired`, `isModifiableDefinition`, `defaultValue`, `valueList` (строка через запятую или массив).
---
## 3. Как ordinary generator читает модель
- **Входной поток:**
`yaml-generator` вызывает `cli.GetService(svc.ID)` и перебирает список `info.Operations`.
- **Чтение операций и параметров:**
Для каждой операции вызывается `cli.GetServiceOperation(op.SvcOperationID)` ([TOOLS/yaml-generator/internal/client/client.go](../TOOLS/yaml-generator/internal/client/client.go#L182-L240)).
- **Преобразования и нормализация:**
- Имена сервисов и операций нормализуются в snake_case функцией `normalize.Identifier` ([TOOLS/yaml-generator/internal/normalize/normalize.go](../TOOLS/yaml-generator/internal/normalize/normalize.go#L17-L50)).
- Классификация операции: функция `classifyOperation` делит операции на `instance` (для `create`, `modify`, `delete`, `suspend`, `resume`), `subresource` (если есть символ подчеркивания) или `action`.
- Разворачивание `dataDescriptor`: генератор обходит `map[string]CfsSubParam`, преобразует подполя в срез `types.ParamSpec` и детерминированно сортирует по `subParams[i].ID`.
- Сортировка верхнеуровневых параметров по `params[i].ID`.
- Сортировка операций по имени и ID.
- **Что отбрасывается / не сохраняется в YAML:**
- Конкретные HTTP method и URL-пути эндпоинтов API платформы (они зашиты в код клиента, в спек YAML не пишутся).
- Вспомогательные поля `CfsParam`, не имеющие тега `yaml:` в [TOOLS/lib/types.go](../TOOLS/lib/types.go#L70-L101), если они не сериализуются или приходят пустыми: `IsModifiable`, `IsSensitive` (указаны с `omitempty`). Поле `dataDescriptor` как мапа отбрасывается — сохраняется преобразованный срез `sub_params`.
---
## 4. Как формируется API YAML
- Сериализация структуры `types.ServiceSpec` в YAML выполняется через библиотеку `gopkg.in/yaml.v3` ([TOOLS/yaml-generator/main.go](../TOOLS/yaml-generator/main.go#L107-L114)).
- В результирующий файл пишутся:
- Метаданные сервиса (`name`, `service_id`, `service_display_name`, `service_short_name`, `service_man`).
- Стандартная секция `lifecycle` и `outputs`.
- Секция `operations` со списком операций и всеми их параметрами (`id`, `code`, `data_type`, `required`, `default`, `value_list`, `descr`, `man`, `sort`, `sub_params`).
---
## 5. Цепочка данных для `vc_org.vIPConfigure.count`
1. **API Model:**
- Эндпоинт `/instanceOperations/default/207` возвращает параметр с `svcOperationCfsParamId: 662`, `svcOperationCfsParam: "vIPConfigure"`, `dataType: "array-map-fixed"`.
- Внутри него поле `dataDescriptor` содержит ключ `"count"`:
- `svcOperationCfsSubparamId: 40`,
- `dataType: "integer > 0"`,
- `isRequired: true`,
- `defaultValue: ""`,
- `descr: "Пример: \`1\`"`.
2. **Generator Input:**
- Читается в структуру `types.CfsParam` с мапой `DataDescriptor map[string]CfsSubParam` ([TOOLS/yaml-generator/internal/types/types.go](../TOOLS/yaml-generator/internal/types/types.go#L49-L72)).
3. **Internal Generator Representation:**
- В [TOOLS/yaml-generator/internal/client/client.go](../TOOLS/yaml-generator/internal/client/client.go#L210-L232) мапа `dataDescriptor` разворачивается в `types.ParamSpec.SubParams`. Поле `count` становится элементом среза `SubParams` с `ID: 40`, `Code: "count"`, `DataType: "integer > 0"`.
4. **Generator Transformation:**
- Сортируется по ID (`sort.Slice(subParams, ...)`).
5. **API YAML:**
- Записывается в [generated/dev/resources_yaml/19_vc_org.yaml](../generated/dev/resources_yaml/19_vc_org.yaml#L131-L150):
```yaml
- id: 662
code: vIPConfigure
data_type: array-map-fixed
required: true
sort: 10
sub_params:
- id: 39
code: name
data_type: string
required: true
default: ""
is_modifiable: false
- id: 40
code: count
data_type: integer > 0
required: true
default: ""
descr: 'Пример: `1`'
is_modifiable: false
```
- **Потерь в YAML нет:** `vIPConfigure` и `count` полностью и без искажений сохранены в каноническом YAML спецификации.
---
## 6. Цепочка данных для `vc_nsxt.ipSpaceName` и связанных параметров
1. **API Model:**
- Эндпоинт `/instanceOperations/default/111` возвращает операцию `modify` сервиса 22.
- Параметр `ipSpaceName` возвращается с:
- `svcOperationCfsParamId: 372`,
- `svcOperationCfsParam: "ipSpaceName"`,
- `dataType: "string"`,
- `isRequired: false`,
- `descr: "Имя ip Space для внешнего IP"`,
- `man: "Необходимо указывать, если включён параметр \`Выделить VIP для SNAT\`"`,
- `sort: 50`.
- В HAR-дампах ([HAR/edge_.har](../HAR/edge_.har#L7985)) на живом инстансе в рантайме возвращается `valueList: ["no-needed", ...]`.
2. **Generator Input:**
- Читается в структуру `types.CfsParam` ([TOOLS/yaml-generator/internal/types/types.go](../TOOLS/yaml-generator/internal/types/types.go#L49-L72)).
3. **Internal Generator Representation:**
- Преобразуется в `types.ParamSpec` со значениями `ID: 372`, `Code: "ipSpaceName"`, `DataType: "string"`.
4. **Generator Transformation:**
- Нормализуются defaults и value_list через `normalizeDefault` и `normalizeValueList`.
5. **API YAML:**
- Записывается в [generated/dev/resources_yaml/22_vc_nsxt.yaml](../generated/dev/resources_yaml/22_vc_nsxt.yaml#L149-L155):
```yaml
- id: 372
code: ipSpaceName
data_type: string
required: false
descr: Имя ip Space для внешнего IP
man: Необходимо указывать, если включён параметр `Выделить VIP для SNAT`
sort: 50
```
- Рядом в той же операции сохранены: `needEnableAVI` (ID 368), `virtualServicesCount` (ID 369), `qosProfile` (ID 856), `routedNetConfiguration` (ID 1112 с sub_params).
- **Особенность по `value_list`:** в статическом `22_vc_nsxt.yaml` поле `value_list` для `ipSpaceName` отсутствует (`null` в ответе static-дефолтов эндпоинта `/instanceOperations/default/111`), хотя в рантайме на конкретном инстансе `valueList` динамически содержит `["no-needed", ...]`.
---
## 7. Что сохраняется
- Полная идентичность сущностей: `service_id`, `svcOperationId` (как `id` операции), `svcOperationCfsParamId` (как `id` параметра), `svcOperationCfsSubparamId` (как `id` в `sub_params`).
- Строковые коды: `operation`, коды параметров (`code`).
- Исходные типы платформы: `dataType` (`string`, `boolean`, `integer > 0`, `array-map-fixed`, `map-fixed`).
- Вся структура вложенности (`dataDescriptor` → `sub_params`).
- Флаги `required`, валидационные regex, min/max, описания (`descr`, `man`), порядок (`sort`).
---
## 8. Что преобразуется или нормализуется
- Имена операций и сервисов приводятся к ASCII snake_case через `normalize.Identifier`.
- `dataDescriptor` из мапы ключей преобразуется в упорядоченный срез `sub_params` с сортировкой по числовому `id`.
- Значения `valueList` и `default` приводятся к строковым представлениям (убираются пробелы, пустые значения приводятся к `nil`).
---
## 9. Что теряется
- HTTP-метод и путь обращения к API (в YAML отсутствуют; генератор считает их внешним знанием рантайма).
- Динамические значения списков выбора (`valueList`): эндпоинт дефолтов `/instanceOperations/default/{id}` возвращает пустой `valueList` для полей, зависящих от конкретного тенанта/инстанса (например, доступные `ipSpaceName` для конкретного VDC/Org).
---
## 10. Где происходят потери
- Потери HTTP-метаданных (метод, URL) происходят на этапе маршалинга структуры `types.ServiceSpec` в YAML ([TOOLS/yaml-generator/main.go](../TOOLS/yaml-generator/main.go#L94-L105)), так как они изначально отсутствуют в контракте [TOOLS/lib/types.go](../TOOLS/lib/types.go).
- Отсутствие runtime `valueList` обусловлено вызовом шаблонного эндпоинта `/instanceOperations/default/{id}` вместо запроса контекста живого инстанса.
---
## 11. Какие данные доступны в API YAML
- Полный перечень всех сервисов, операций (`create`, `modify`, `delete`, `suspend`, `resume`, сабресурсов) и их параметров.
- Точные типы платформы (`data_type`) и иерархия подполей (`sub_params`).
- Идентификаторы `id` (CFS param IDs) и символические коды (`code`).
- Метаданные валидации (обязательность, регулярные выражения, ограничения диапазонов).
---
## 12. Какие данные доступны только в API-модели
- Динамические списки допустимых значений (`valueList`), вычисляемые бэкендом для конкретного состояния конкретного инстанса (например, список реально существующих ipSpaces организации при вызове modify на Edge).
- Внутренние служебные поля платформы CFS, отфильтрованные моделью генератора (`contractId`, `contragentId`, `nestedRefData`, `config`, `statePath`, `expression`).
---
## 13. Неизвестные места
- Неизвестно, возвращает ли платформа Nubes какую-либо схему валидации для эндпоинтов отката/деаллокации (например, принимает ли `vc_org.modify` пустой массив `vIPConfigure: []` для полного снятия или требует только уменьшения `count: 0`), так как в дефолтной модели операции 207 описан только общий формат `vIPConfigure`.
---
## 14. Что необходимо проверить фактическим API
- Поведение `vc_org` (операция 207) при передаче `vIPConfigure: []` против `vIPConfigure: [{"name": "...", "count": 0}]` при попытке полной деаллокации IP-пространства.
- Поведение `vc_nsxt` (операция 111) при передаче `ipSpaceName: "no-needed"` на различных окружениях (Dev/Test/Prod).
---
## Мнение Opus: финальное ревью отчёта
Отчёт признан годным и принят как вход для дальнейшего архитектурного этапа.
### Достаточно для дальнейшей работы
- Цепочка `API model → yaml-generator → API YAML` прослежена по коду, а не по догадкам: `classifyOperation`, `params.Merge`, разворачивание `dataDescriptor` в `sub_params`.
- Обе обязательные трассировки (`vc_org.vIPConfigure.count`, `vc_nsxt.ipSpaceName`) доведены до YAML с подтверждением «потерь нет».
- Зафиксирован ключевой факт: динамический `valueList` есть только в рантайме живого инстанса, а в API YAML его нет. Это прямое ограничение для будущего modifier-слоя.
- Неизвестное поведение payload деаллокации (`vIPConfigure: []` против `count: 0`) помечено как `unknown`, а не закрыто предположением.
### Следствия перед архитектурным этапом
- Под ярлыком «Ordinary Resource Generator» в отчёте упоминаются два разных инструмента: `yaml-generator` пишет спеки, а `resource-generator` создаёт Go-ресурсы. Для проектирования `ModifierSpec` это две разные точки вмешательства.
- `vIPConfigure` и `ipSpaceName` присутствуют только в `modify` и отсутствуют в `create`. В текущей схеме они сливаются в общий ресурс и не имеют отдельного жизненного цикла. Это корень задачи модификаторов.
- Runtime-`valueList` придётся получать не из дефолтного эндпоинта, а из контекста инстанса. Это вопрос рантайма провайдера, а не генератора.
### Итоговая оценка
Ошибок, которые ломали бы выводы отчёта, не выявлено. Отчёт можно использовать как подтверждённую основу для дальнейшего проектирования.
@@ -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,69 @@
# HAR-разбор: SNAT / ipSpace / модификации (dev)
Дата: 2026-09-22. Источник: `/home/naeel/TF/tf_provider/HAR/*.har` (записи UI на dev-стенде, 2026-09-20).
Релевантные файлы: `edge_.har` (SNAT/Edge), `ipSpace0.har`, `org_enough_.har`, `org_not_enough_.har`, `org0.har`.
## Поток modify в реальном API
1. `POST /api/v1/svc/instanceOperations` — `{"instanceUid":"...","operation":"modify"}` → возвращает `instanceOperationUid`.
2. `POST /api/v1/svc/instanceOperationCfsParams` — по одному запросу на параметр:
`{"paramValue":"...","instanceOperationUid":"...","svcOperationCfsParamId":NNN}`.
3. `GET /svc/instanceOperations/{id}/validate-cfs`
4. `POST /svc/instanceOperations/{id}/run`
5. Поллинг `GET /svc/instanceOperations/{id}`.
## Найденные payload-и
| Операция | param id | код | значение из HAR |
|---|---|---|---|
| edge modify | 368 | `needEnableAVI` | `false` / `true` |
| edge modify | 369 | `virtualServicesCount` | `1` / `2` |
| edge modify | 856 | `qosProfile` | `QoS-100Mbit` |
| edge modify | **372** | **`ipSpaceName`** | **`no-needed`** / `""` |
| edge modify | 1112 | `routedNetConfiguration` | `{"mainDns":"81.22.46.22","secondDns":"185.247.187.77","ipAddrPool":"10.10.102.0/24"}` |
| org modify | **662** | **`vIPConfigure`** | `[{"name":"internet-ipv4-v1","count":"3"}]` |
## Ответы на открытые вопросы
1. **Тумблера «Выделить VIP для SNAT» в API НЕТ.** SNAT управляется целиком через `ipSpaceName` (param 372).
Его `valueList` (из метаданных в HAR): `no-needed, internet-antiddos-v1, internet-no-antiddos-v1, ...` — то есть `no-needed` это легальное значение «SNAT не нужен».
- Включить SNAT: `ipSpaceName = <имя ipSpace из org>`.
- Выключить: `ipSpaceName = "no-needed"`.
2. ✅ **Каноническое «SNAT выключен» = `no-needed`.** Подтверждено: в UI (Edge → Modify → поле «ip Space для VIP», параметр `ipSpaceName`) текущее значение показывается как `no-needed`. Reverse для SNAT = `modify` с `ipSpaceName="no-needed"` → delete SNAT-модификатора можно реализовать не как no-op. (`""` из `ipSpace0.har` — не каноническое, а промежуточное состояние.)
3. 🟡 **Де-аллокация IP в org — попытка зафиксирована (`org2.har`, 2026-09-22):** UI отправил `modify` с
`vIPConfigure=[{"name":"internet-ipv4-v1","count":"2"}]` (count уменьшен с 3 до 2).
HTTP-ошибки НЕТ, но операция осталась в `isPending:true` — не выполнилась (согласуется с ограничением ниже).
**Вывод:** payload де-аллокации = ТА ЖЕ структура `vIPConfigure`, только меньше `count` (не отдельная операция).
Точная семантика «удалить совсем» (`count=0` или опустить элемент) не подтверждена.
🔴 **Ограничение (подтверждено):** уменьшить/удалить ipSpace в `vcOrg` **нельзя, пока существуют дочерние инстансы** (VDC/Edge/кластер).
Следствие: reverse возможен только ПОСЛЕ уничтожения детей → порядок destroy критичен:
`кластер → SNAT-модификатор (no-needed) → org IP de-alloc → edge → vdc → org`.
Чтобы снять payload «удалить совсем», нужен чистый org без детей (или плановый teardown).
## Побочные факты
- У `ipSpaceName` (372) в API есть `valueList`, но в нашем YAML его **нет** → проверить, тянет ли генератор `valueList` (возможно, он динамический: имена ipSpace конкретной org).
- Имя ipSpace в живом примере — `internet-ipv4-v1` (не произвольное).
- `qosProfile` (856) UI всегда шлёт как `QoS-100Mbit`.
- `routedNetConfiguration` передаётся JSON-строкой.
- В состоянии org: `"vip":{"no-needed":{},"internet-ipv4-v1":{"count":4}}` — `no-needed` фигурирует и в стейте.
## Наблюдения на возможно сломанном Edge (2026-09-22, nsx_WZ03709-saas-wmfop5be)
⚠️ ВАЖНО: этот Edge, судя по всему, в сломанном состоянии (devops-проблема).
Ошибки ниже **НЕ считать универсальными правилами API** — перепроверить на здоровом Edge.
1. `modify` 14:40:52 → «ipSpace '' не найден на https://sandbox.nubes.ru» — при пустом `ipSpaceName` бэкенд отклонил запрос. ❓ Возможно, следствие сломанного Edge, не правило.
2. `modify` 14:43:44 → «Insufficient rule blocks» при попытке снять «Включить ALB». ❓ Возможно, застрявшие VS/SE Group, не правило.
3. `delete` (2 раза) → FORBIDDEN «Cannot delete SE Group assignment … since there are Virtual Services». ❓ Возможно, застрявшие VS, не правило.
**Что остаётся надёжным (из API-метаданных, НЕ из этих ошибок):**
- `valueList` у `ipSpaceName` содержит `no-needed` (+ имена ipSpace) — из описания параметра.
- UI показывает `no-needed` как текущее значение при выключенном SNAT.
## Что ещё нужно выяснить из UI (открытые вопросы)
1. 🔴 **Де-аллокация IP в org — пока НЕ снять:** UI/бэкенд не даёт удалить ipSpace, пока есть дочерние инстансы (подтверждено 2026-09-22). Нужен чистый org или плановый teardown. Гипотеза payload — `vIPConfigure=[]` (unverified).
2. ✅ **Имя ipSpace — выбор ИЗ СПИСКА** (подтверждено UI). Свободного ввода нет → список динамический (текущие ipSpace org + `no-needed`).
Следствие для провайдера: `ip_space_name` в SNAT-модификаторе должен браться из **computed-вывода org-модификатора**, а не быть свободной строкой.
3. 🟡 Полное удаление ipSpace и поведение при destroy Edge с включённым SNAT — на будущее (блокировано п.1).
@@ -0,0 +1,122 @@
# Ответ Opus: анализ решения IaC-развёртывания Штурвала (модификаторы + скрытые зависимости)
**Дата:** 2026-09-23
**Связанный промпт:** `NOTES/20_prompts/prompt_for_opus_iac_shturval_modify.md`
**Связанный анализ:** `NOTES/30_analysis/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md`
**Статус:** документирование ответа. Конкретный план НЕ составляется.
> ⚠️ **ВАЖНАЯ ПОПРАВКА (2026-09-23, позже).** Ответ Опуса ниже строился на НЕВЕРНОЙ посылке «`vIPConfigure` — накопительный API». Это опровергнуто тестом `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md`: `vIPConfigure` ведёт себя как **replace-состояние** — идемпотентно (1→1), работает в обе стороны (вверх/вниз/до 0), `count` читается из `state.params`. Соответственно «блокеры» (a) Read счётчика и (b) адресное освобождение **сняты как ложные**. Остаётся только (c) Read цепочки `providerVdc → providerGateway → ipSpace`. НЕ использовать прежнюю формулировку «накопительный API несовместим с декларативной моделью» как источник истины.
---
## 1. Суть ответа (главный вывод)
Форма IaC-ресурсов фиксируется **уже сейчас**, потому что она диктуется моделью Terraform (декларативность, идемпотентность, inverse), а не спеками платформы.
НО есть **три блокера от платформы**, без которых идемпотентность и Delete принципиально недостижимы на стороне провайдера.
---
## 2. Ответ Opus по пунктам
### Пункт 1 — накопительный `vIPConfigure` → идемпотентный ресурс
- Ресурс отдельный (`nubes_org_vip_allocation`) с `depends_on` на оргу, НЕ операция внутри орги.
- **Ключ идемпотентности — желаемое состояние, а не дельта.** Юзер задаёт целевой `count` на `name`; провайдер сам считает `target − current` и модифицирует только разницу.
- **Create:** Read текущего числа vIP → выделить `target − current`. Если API не отдаёт «сколько уже есть» — нужен серверный счётчик/тег, иначе идемпотентность недостижима.
- **Read:** читать родителя (оргу), извлекать фактическое число IP по `name` в state. Если API не различает «кем/зачем выделено» — Read вернёт общий пул, drift неизбежен.
- **Update:** та же дельта-логика (target изменился → доначислить/освободить).
- **Delete (inverse):** `modify` с обратным знаком до `count=0` по этому `name`. Требует адресного освобождения конкретных IP. Если освобождение — тоже накопительный modify без адресации, inverse корректно сделать нельзя.
**Риск (ОПРОВЕРГНУТ позже):** это утверждение строилось на ложной посылке «накопительный API». Факт: `vIPConfigure` — replace-состояние, дельта `target − current` по факту не нужна — достаточно слать целевой `count`, платформа сама выставляет его (идемпотентно). См. `ORG_IP_MODIFIER_TEST_2026-09-22.md`.
### Пункт 2 — `ipSpaceName` (цепочка providerVdc → providerGateway → ipSpace)
- Это **выводимое значение из инфраструктуры, НЕ пользовательский ввод** → data-source, а не аргумент ресурса.
- Правильно: `data "nubes_ip_space" { org/vdc = ... }`, который проходит цепочку providerVdc → providerGateway → ipSpace и возвращает `name`. Ресурс берёт значение по ссылке.
- **Граница «данные vs логика»:** в реестре хранить **тип поля и его источник** (что это computed-from-parent, а не user-input). Сама цепочка обхода — логика data-source, не данные реестра.
- НЕ вычислять из state родителя вручную в ресурсе (скрытая связанность, ломается при >1 T0). Data-source явно выражает зависимость в графе tf.
- Пока платформа «подкладывает» значение сама — data-source должен уметь то же читать. Если API этой цепочки нет на чтение — **блокер**.
### Пункт 3 — не завязываться на «один T0»
- Закладывать **явный селектор шлюза** уже сейчас: `provider_gateway` / `t0_id` как аргумент (или ключ data-source), даже если сегодня один и выводится автоматически (optional + computed default).
- vIP-аллокация и SNAT привязывать к **конкретному gateway id**, а не к «дефолтному в орге».
- **Что сломается при >1 T0, если не заложить:** `ipSpaceName` станет неоднозначным (несколько ipSpace), vIP-аллокация не будет знать, к какому шлюзу. Придётся менять схему (добавлять обязательный селектор) → breaking change.
- **Заложив optional-селектор сейчас:** при росте T0 меняется только default-резолвинг, схема остаётся совместимой.
### Пункт 4 — ждать спеки или фиксировать форму сейчас
- **Форму ресурсов можно и нужно фиксировать сейчас** — она диктуется моделью Terraform, а не спеками.
- **Не блокер (делаем сейчас):** раздельные ресурсы + `depends_on`; целевое состояние вместо дельты; селектор шлюза; data-source для `ipSpaceName`; inverse через обратный modify.
- **Блокер (нужно от платформы) — только один подтверждённый:**
- c) API чтения цепочки providerVdc → providerGateway → ipSpace (иначе data-source невозможен).
- **Сняты как ложные (опровергнуты тестом 2026-09-22):**
- a) чтение текущего числа vIP — УЖЕ работает через `state.params.vIPConfigure`;
- b) адресное освобождение IP — УЖЕ работает: `count` меньше/`0` задаётся тем же `modify`, в обе стороны.
- **Вывод:** проектируем форму сейчас, блокер только (c). Новые спеки повлияют на **резолвинг значений**, не на форму ресурсов — если форма построена на «целевое состояние + селектор + data-source».
### Пункт 5 — минимально-инвазивный порядок внедрения
**От платформы (до кодинга ресурсов) — обязательно:**
- Read цепочки → `ipSpaceName` (для data-source).
- Подтверждение, что генератор умеет строить схему из объединения `create`+`modify` полей (иначе `vIPConfigure`/`ipSpaceName` вообще не попадут в схему).
> ⚠️ Read счётчика vIP и адресное освобождение — НЕ блокеры (уже подтверждено тестом). Исключены.
**На стороне провайдера — можно сейчас, не дожидаясь:**
- Раздельные ресурсы vip-allocation / nsxt-snat с `depends_on`.
- Логика «target − current = дельта» (заглушка current, пока нет Read).
- Data-source-скелет для `ipSpaceName` (с TODO на реальный обход цепочки).
- Optional+computed селектор шлюза.
- Inverse-контракт (Delete = обратный modify до нуля).
**Главный неустранимый на нашей стороне блокер:** ❌ СНЯТ — строился на ложной посылке «накопительный API». Реальный остаточный блокер — только (c) чтение цепочки providerVdc → providerGateway → ipSpace (для data-source `ipSpaceName`).
---
## 3. Что нового vs то, что уже собирались делать
### Совпадает со старыми планами (НЕ новое)
- Отдельный ресурс под модификацию + `depends_on` — было (`PLAN_modifier_redesign.md`, «resource association»).
- Inverse через обратный modify (`count→0`) — было (`inverse_rollback_analysis_2026-09-23.md`).
- Идемпотентность (skip run, если live уже целевое) — было.
- «Не ждать спеки для формы, а фиксировать сейчас» — по сути было.
### Реально новое у Опуса
1. **«Целевое состояние, а не дельта»** — строгий принцип: юзер задаёт целевой `count`, провайдер сам считает `target − current`. Старые планы просто «досылали заданные поля», не формализовали желаемое состояние.
2. **`ipSpaceName` — data-source, не аргумент ресурса** — сдвиг от «юзер вписывает значение» к «computed-from-parent». Раньше виделось как ввод.
3. **Селектор шлюза (`t0_id`/`provider_gateway`) как optional+computed сейчас** — в старых планах про «один T0» вообще не было (пришло только из реплики Виталия).
4. **Чёткая граница «данные vs логика»** — в реестре только «тип поля + что computed-from-parent», цепочка обхода — логика data-source.
5. **Блокеры от платформы** — из трёх заявленных Опуса два (Read счётчика, адресное освобождение) **ложны** (опровергнуты тестом), остаётся один реальный: Read цепочки providerVdc→providerGateway→ipSpace.
### Главное отличие одной фразой
Старые планы отвечали на «**как сделать модификатор в tf**». Опус отвечает на «**как сделать его идемпотентным и IaC-честным**» — но его центральный вывод «ядро проблемы в платформе (накопительный API)» **оказался ошибочным**, т.к. исходная посылка «накопительный» неверна (см. поправку в шапке). Реальный остаток — только `ipSpaceName` (цепочка providerVdc→providerGateway→ipSpace) и селектор шлюза.
---
## 4. Спорный/непроверенный момент — РАЗРЕШЁН
Посылка Опуса «накопительный `vIPConfigure` без Read-счётчика и адресного освобождения несовместим с декларативной моделью» **опровергнута** тестом `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md`:
- повторный `modify` с тем же `count` не аккумулирует IP (1→1) → идемпотентно;
- `count` меняется в обе стороны (2→1→0) через тот же `modify` → «адресное освобождение» не нужно, достаточно задать меньший/нулевой `count`;
- `count=0` принимается (несмотря на `minvalue:1` в схеме), элемент ipSpace остаётся в `state.params`;
- Read уже есть: `state.params.vIPConfigure = [{"name":"internet-ipv4-v1","count":N}]`.
Единственный реально непроверенный момент: полное удаление ipSpace (`[]` / отсутствие элемента) — тест этого не покрывал. Для IaC-задачи «обнулить» достаточно, полное удаление — опционально.
---
## 5. Резюме (с поправкой)
- Форма ресурсов — проектируем сейчас, она не зависит от спеков.
- `vIPConfigure` — **НЕ блокер**: идемпотентно, обе стороны, `count` читается/задаётся из `state.params` (тест 2026-09-22).
- Единственный подтверждённый блокер: **Read цепочки `providerVdc → providerGateway → ipSpace`** для data-source `ipSpaceName` (c).
- Непроверено: полное удаление ipSpace (`[]`), но для задачи достаточно `count=0`.
- Конкретный план внедрения пока НЕ составляется (по решению пользователя).
**Следующий возможный шаг (только по запросу):** проверить (c) чтение цепочки ipSpace живым API.
@@ -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,35 @@
# Проверка модификатора vc_org ip_space (modify 207) — 2026-09-22
Организация: `NarodOrg` (`9890a8a0-040b-4d56-8018-c31519c35a30`), realm `sandbox.nubes.ru`.
Стенд: `DEV_STAND/FullPipe`, провайдер `nubes-dev` 2.0.9.
## Что делали
1. Переименовали `org_ips.tf` → `terraform apply` — ошибок нет (ресурс ушёл из state; `Delete` модификатора — no-op).
2. Вернули файл → `apply` — ошибок нет, **число IP осталось 1** (повторный `modify` с тем же `count=1` НЕ задвоил).
3. `org_ip_count = 2` → `apply` — стало 2.
4. `org_ip_count = 1` → `apply` — стало 1.
5. `org_ip_count = 0` → `apply` — стало 0.
## Подтверждено по API
`GET /instances/9890a8a0-040b-4d56-8018-c31519c35a30`:
- `state.params.vIPConfigure = [{"name":"internet-ipv4-v1","count":0}]`
- последняя операция `modify` — `isSuccessful: true`, `isPending: false`, `isInProgress: false`.
## Выводы (обновляют прежние гипотезы)
1. **modify работает в обе стороны** (count вверх/вниз/до 0) на здоровой орге, даже при живых дочерних инстансах (VDC/Edge).
2. **modify идемпотентен** — повторный `modify` с тем же `count` не аккумулирует IP (1 → 1).
3. **`count=0` принимается**, хотя в схеме операции 207 у `count` стоит `minvalue: 1` / `integer > 0` — валидация не отвергает 0. `count=0` = ноль выделенных IP (элемент ipSpace остаётся в state).
4. **`Delete` модификатора — no-op** подтверждён (шаг 1), но это восполнимо: повторный `apply` с нужным `count` корректно восстанавливает состояние.
## Опровергнуто
- Утверждение из `NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md` «уменьшить/удалить ipSpace нельзя, пока существуют дочерние инстансы» — **не подтвердилось на здоровой орге** (в HAR был сломанный Edge; это и было помечено как неподтверждённое наблюдение).
- Опасение из Opus-ревью о «двойном выделении при replace/destroy→apply» — в части повторного `modify` с тем же `count` **не воспроизвелось** (идемпотентно).
## Открытый вопрос
- Полное удаление ipSpace (пустой массив `[]` / отсутствие элемента) не тестировалось — `count=0` оставляет элемент `{"name":"internet-ipv4-v1","count":0}` в state.
+38
View File
@@ -0,0 +1,38 @@
# 30_analysis — анализы, отчёты, разборы
Фактические материалы: форензика, разборы багов, ответы LLM-ревью, аналитика по кодовой базе.
> Правило: **факт без источника не факт.** Ниже у каждого файла указано, проверен ли он на стенде.
## Ядро по задаче «IaC + modify» (актуально)
| Файл | Что внутри | Статус |
|---|---|---|
| `SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md` | Анализ: почему `modify` не выражается, прецедент VCD, скрытые зависимости (`providerVdc → providerGateway → ipSpace`), варианты A–E, мнение | ✅ Актуально |
| `OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md` | Ответ Opus по промпту IaC **+ поправка**: два его «блокера» (Read счётчика, адресное освобождение) ложны | ✅ Актуально (читать с поправкой в шапке) |
| `ORG_IP_MODIFIER_TEST_2026-09-22.md` | **Единственная проверка на живом стенде**: `vIPConfigure` идемпотентен (1→1), работает вверх/вниз/до 0, `count=0` принимается, Read из `state.params` | ✅ Актуально, высокое доверие |
## Форензика и отладка
| Файл | Что внутри | Статус |
|---|---|---|
| `FORENSIC_ANALYSIS_BRIEF_2026-09-23.md` | Бриф: что исследовать в цепочке API → YAML, строгие ограничения (не проектировать решения) | ✅ Актуально как рамка |
| `FORENSIC_ANALYSIS_REPORT_2026-09-23.md` | Отчёт по брифу: потерь данных API→YAML нет; теряются динамические `valueList` и HTTP-метаданные | ✅ Актуально |
| `HAR_SNAT_MODIFY_FINDINGS.md` | Находки по SNAT/`modify` из HAR. ⚠️ Часть утверждений **опровергнута** тестом 2026-09-22 (см. `ORG_IP_MODIFIER_TEST…`) | ⚠️ Частично устарело |
| `DEBUG_REPORT_VC_VDC_500.md` | Разбор ошибки 500 при работе с vc/vdc | ⚠️ Историческое |
| `DEBUG_REPORT_VM_FIX.md` | Разбор/фикс проблемы с VM | ⚠️ Историческое |
| `inverse_rollback_analysis_2026-09-23.md` | Inverse-откат: `delete_params {Code, Mode, Value, zero_fields, off_value}`, порядок destroy. Модель — от отменённого механизма; факты (`count=0`, `no-needed`) верны | ⚠️ Частично устарело (баннер в файле) |
## Обзоры кодовой базы и архитектуры
| Файл | Что внутри | Статус |
|---|---|---|
| `CODEBASE_ANALYSIS_AND_ROADMAP.md` | Обзор репозитория + roadmap | ⚠️ Проверить актуальность по дате |
| `ARCHITECTURE_NEW.md` | «Universal Rebuild»: ядро, генераторы, YAML-спеки. Ссылается на пути `universal_rebuild/*` | ⚠️ Историческое (пути не совпадают с текущим репо) |
| `YAML_ANALYSIS_43_SERVICES.md` | Анализ 43 сервисов по YAML-спекам | ⚠️ Проверить актуальность по дате |
| `opus_review_answer.md` | Ответ Opus на `prompt_for_opus_review.md` (ревью архитектуры, 3 задачи roadmap) | ⚠️ Историческое |
## Связанное
- Сырые данные: `../../HAR/` (дампы live-запросов), `../../generated/<стенд>/resources_yaml/` (спеки).
- Хендовер по текущему состоянию: `../40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
@@ -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,169 @@
# Штурвал через IaC: анализ проблемы `modify` и скрытых платформенных зависимостей
**Дата:** 2026-09-23
**Контекст:** дискуссия в Telegram про запуск цепочки Штурвал полностью через Terraform.
---
## 1. Исходная задача
Клиенту нужен IaC (Infrastructure as Code): вся инфраструктура описывается одним конфигом, команда `terraform apply` разворачивает её целиком, `plan`/`destroy` дают полную картину. Никаких обязательных ручных шагов посередине.
Цепочка для стенда Штурвал:
```text
vcOrg -> create
vcVdc -> create
vcNsxt -> create
------------------------------
vcOrg -> modify (аллоцировать внешние IP в организацию)
vcNsxt -> modify (включить SNAT, указать внешний IP из vcOrg)
------------------------------
k8sShturval -> create
```
Ключевой конфликт: `create` у Terraform работает штатно, а операции `modify` в текущем провайдере никак не выражаются — Terraform не умеет «создать ресурс, а через несколько шагов поменять в нём же параметр».
---
## 2. Почему `modify` не выражается в текущем провайдере
### 2.1. Генератор строит схему только из `create`
Провайдер генерируется из YAML-спеков (`generated/{stand}/resources_yaml/*.yaml`). Схема ресурса (какие поля можно писать в `.tf`) строится **только из операции `create`**. Параметры, которые есть только в `modify`, в схему не попадают.
Подтверждено по файлам:
- `generated/dev/resources_yaml/19_vc_org.yaml`:
- `create` (id 136) → только `resourceRealm` (418), `organizationType` (556), `orgSuffix` (1125);
- `vIPConfigure` (выделение внешних IP) есть **только** в `modify` (id 207), с sub-полями `name` (39) и `count` (40).
- `generated/dev/resources_yaml/22_vc_nsxt.yaml`:
- `create` (id 10) → `vdcUid`, `needEnableAVI` (340), `virtualServicesCount` (341), `qosProfile` (825), `routedNetConfiguration` (1110) и др.;
- `ipSpaceName` (372) есть **только** в `modify` (id 111).
Вывод: `vIPConfigure` (vc_org) и `ipSpaceName` (vc_nsxt) живут только в `modify`, в схеме tf-ресурсов их нет. Поэтому «прописать параметр в tf и сделать apply» падает ещё на `plan` (атрибут не известен провайдеру).
### 2.2. Эти параметры — не «настройки», а отложенные действия
- `vIPConfigure=[{name,count}]` — задаёт желаемое число внешних IP целиком. **Не накопительная** (повторный вызов с тем же `count` не аккумулирует, подтверждено `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md`), работает в обе стороны (вверх/вниз/до `count=0`). Это **декларативное значение** в смысле «желаемое количество IP по данному ipSpace».
- `ipSpaceName` — включение SNAT на конкретный ipSpace, который возникает **только после** того, как на орге выделены IP.
- Эти операции требуют порядка (org.modify → затем nsxt.modify) и зависят от живого состояния инстанса, а не от дефолтов формы.
### 2.3. Схема в state ≠ реальное состояние
Вписывать недостающие параметры «насильно» в tfstate нельзя и бесполезно:
1. Terraform валидирует атрибуты по **схеме провайдера**, а не по state — неизвестный атрибут будет отброшен/вызовет ошибку.
2. Записывать в state «SNAT включён», когда этого нет на площадке, — значит получить ложный `plan` (чистый) при сломанной инфраструктуре.
3. Ручная правка tfstate/`state push` ломает целостность (серийник, конфликты на следующем apply).
Работает только косвенно: `terraform_data`/`null_resource` + `local-exec` → в state попадает **факт** «операция выполнена» (маркер с `triggers`), но не **состояние** SNAT/IP. Порядок задаётся через `depends_on`, но дрейф по самим параметрам `plan` не видит.
---
## 3. Каноничное решение: отдельный ресурс (resource association)
Это принятая в Terraform практика — «resource association / separate resource». Классические примеры:
- `aws_security_group` + `aws_security_group_rule`
- `aws_vpc` + `aws_route_table_association`
- `google_project` + `google_project_iam_member`
Базовый ресурс создаётся отдельно, а донастройка/привязка — отдельным ресурсом с `depends_on`. Граф сам выстраивает порядок, `destroy` разворачивает его корректно.
### 3.1. Прецедент из Cloud Director (VCD)
В репозитории лежит сторонний шаблон — `/home/naeel/TF/tf_provider/!/` (network.tf.tmpl, vmware_org.tf, vdc.tf), показывающий, как та же цепочка делается провайдером VMware Cloud Director:
- `resource "vcd_nsxt_alb_settings"` — включение ALB, `count = var.alb_enable ? 1 : 0`, `depends_on = [vcd_nsxt_edgegateway...]`;
- `resource "vcd_nsxt_alb_edgegateway_service_engine_group"` — выделение SE, `reserved_virtual_services = var.alb_segroup_count`;
- `resource "vcd_network_routed_v2"` — routed-сеть, `edge_gateway_id`, `dns1/dns2/static_ip_pool`;
- `resource "vcd_ip_space_custom_quota"` — квота IP на **оргу**, `depends_on = [edge]`.
Приём «включить/выключить» = `count`. Обратная операция (выключить ALB / снять квоту) получается **удалением ресурса** — inverse логика не нужна.
Маппинг на наши сервисы:
| Nubes API | Канон VCD |
|---|---|
| `needEnableAVI` | `vcd_nsxt_alb_settings` (+ `count`) |
| `virtualServicesCount` | `reserved_virtual_services` в SE-группе |
| `routedNetConfiguration` (mainDns/secondDns/ipAddrPool) | `vcd_network_routed_v2` (`dns1/dns2/static_ip_pool`) |
| `vIPConfigure` (IP на оргу) | `vcd_ip_space_custom_quota` (на оргу) |
### 3.2. Чем наш случай сложнее канона
В классическом паттерне ребёнок — **отдельный объект API** со своим CRUD (правило, association, attachment). Его можно создать/прочитать/удалить.
У нас отдельного объекта нет — есть **операция `modify` над родителем**. Поэтому требуются:
1. `Read` — не свой объект, а чтение состояния родителя;
2. `Delete` — не удаление, а **обратный modify** (inverse);
3. `Create/Update` — вызов той же операции с параметрами;
4. идемпотентность (не дёргать `run`, если live уже целевое) и live-сверку.
Именно поэтому «просто завести поля из modify в схему» не работает — нужна полноценная механика, а не одна правка.
---
## 4. Более глубокая проблема: скрытые платформенные зависимости
Это главное из всей дискуссии (реплики Виталия Зайцева, 18:30–18:34).
### 4.1. `ipSpaceName` нельзя ввести вручную — он выводится
```text
имя ipSpace → зависит от providerGateway
providerGateway → зависит от providerVdc
providerVdc → никто не знает изначально
```
Пользователь **не может** заполнить `ipSpaceName`, потому что это значение выводится из внутренней топологии (providerVdc → providerGateway → ipSpace), а не из того, что он видел в ЛК. Это не «поле, которое забыли отдать через API», а **вычисляемое от скрытых зависимостей** значение.
### 4.2. Текущий костыль платформы
«При создании орги/vdc/edge, если организация ничего не знает про недостающие параметры — они подкладываются». То есть одноразовая подстановка при создании пустой орги, чтобы избавить пользователя от «мучительных приседаний» в ЛК.
### 4.3. Ограничение модели: один T0
«Другая проблема — что будет, если в облаке появится больше 1 T0». Пока принято допущение на уровне кода: **в организации всё одно подключение**. Решение осознанно отложено («пара лет спокойствия»), но для IaC это риск: текущее решение завязано на «в орге всегда один провайдер-шлюз».
### 4.4. Ожидание изменений спеков
«Мне надо увидеть, как спеки поменяются, чтобы понять, что исправлять… Надеюсь, появится сначала в sandbox.nubes.ru, а не в ngcloud». То есть платформа меняется, форма ресурсов зависит от **новых спеков**, и строить модификатор сейчас = работать по устаревшим спекам.
---
## 5. Итог: где правда
1. **Ручной ЛК и скрипт не подходят** — клиент требует IaC (Георгий прав). Это не «костыль против красоты», это невыполнение требования.
2. **Отдельный ресурс под модификацию — необходимое, но не достаточное условие.** Он закрывает «как expressить modify», но не закрывает «откуда юзер возьмёт значения».
3. **Главная блокировка — не Terraform, а платформа.** `ipSpaceName` (и подобные) выводятся из `providerVdc → providerGateway → ipSpace`, которые юзер не знает. Пока платформа не отдаёт эти значения в спеках (или провайдер не резолвит их data-источником), честный IaC не собрать — ни модификаторами, ни скриптом, ни руками.
4. **«Ломается агностичность» — верно, но это не порок, а цена.** Ресурсы-модификаторы доменные и «ручные», как в VCD. Без них IaC невозможен, прецедент — перед глазами (`!/network.tf.tmpl`).
5. **Состояние дел:** платформа в движении (ждут новые спеки). Правильная последовательность — дождаться, что придёт в спеках (snandbox), а затем решать форму ресурса; не строить по старым спекам.
---
## 6. Возможные пути (по убыванию «честности» перед IaC)
| Вариант | Что делает | Вердикт |
|---|---|---|
| **A. Полноценные ресурсы-модификаторы + data-источники** | отдельный tf-ресурс на modify + data-source, резолвящий `ipSpace`. Полный IaC. | правильно, но только после новых спеков |
| **B. Data-source через `http`/`external` + `jsondecode`** | DevOps сам дёргает API и подставляет динамические списки, без правки провайдера | рабочая «дожималка», не полный IaC |
| **C. `terraform_data`/`null_resource` + `local-exec`** | модификации скриптом, факт в state, порядок через `depends_on` | полумера, состояние SNAT/IP вне state |
| **D. Прессеты/дефолтное окружение** | готовый набор компонентов, экспорт через провайдер | снижает боль на старте, IaC не заменяет |
| **E. Ручной ЛК / скрипт вне tf** | модификации руками | не подходит (требование клиента) |
---
## 7. Моё мнение
**Коротко:** для настоящего IaC нужны обе вещи одновременно — **отдельный ресурс под `modify`** и **механизм получения динамических значений** (`ipSpace` и пр.). Пока платформа не отдаёт второе через API/спеки, все «быстрые» способы (скрипт, руками, пресеты) закрывают только симптом, а не требование клиента.
**Рекомендация:** не городить модификатор сейчас по устаревшим спекам. Дождаться изменений спеков (сначала sandbox), параллельно — обсчитать два blockers: (1) как провайдер будет резолвить `providerVdc → providerGateway → ipSpace` без ручного ввода; (2) допущение «один T0». После этого проектировать форму ресурсов.
**Что точно не делать:** вписывать параметры «насильно» в tfstate; ждать, что «прописал поле в tf → apply» заработает без правки провайдера. (`vIPConfigure` при этом НЕ накопительный — см. §2.2, тест 2026-09-22.)
@@ -0,0 +1,58 @@
> ⚠️ **ЧАСТИЧНО УСТАРЕЛО (пометка 2026-09-24).** Модель `delete_params`/`inverse`/`off_value` относится
> к **отменённому** механизму модификаторов (метки в YAML + реестр в генераторе).
> Но факты внутри — верны и переиспользуются: `count=0` канонический inverse, порядок destroy,
> `ipSpaceName="no-needed"`, `needEnableAVI=false`. Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`.
# Inverse-откат модификаторов: анализ ответа Опуса — 2026-09-23
Источник: prompt_for_opus_inverse_architecture.md → ответ Опуса (принят, анализ ниже).
## Принятые решения (по Опусу)
1. **Модель delete_params** → заменить плоский `{Code, Value}` на `{Code, Mode, Value?}`:
- `Mode: static` — значение из `Value` (дефолт, обратная совместимость).
- `Mode: zero_count` — обнулить integer-поля в элементах array-map-fixed, взяв live.
2. **Баланс данные/логика**: форма преобразования выводится из `dataType`
(boolean→"false", array-map-fixed→zero integer); сентинелы-значения — ТОЛЬКО данные в реестре.
3. **Маркер поля**: явный `zero_fields:["count"]` (или флаг на sub_param), а НЕ «обнулить все integer»
(риск: порт/приоритет/индекс в том же object).
4. **Порядок destroy** — обратный порядок создания из `depends_on`.
## Мои замечания к ответу (что Опуc недоговорил)
- **A. Граф уже правильный.** Факт: `edge_net` (SNAT) зависит от `org_ips` (IP), `org_ips` — от `edge`.
Обратный порядок destroy: `edge_net → org_ips → edge → vdc` уже корректен.
Рекомендация Опуса «сделать ip_space зависимым от edge_net» — перепутана направлением; граф уже такой.
- **B. Источник live для zero_count в Delete не указан.** `Delete` модификатора имеет только TF `state`,
а live `vIPConfigure` надо читать через `GetInstanceStateParams` в рантайме Delete.
- **C. Отличие sentinel от имени в valueList не разобрано** (valueList без разметки sentinel в данных API).
- **D. Идемпотентность zero_count при повторном destroy не поднята** (count=0 → снова 0: no-op?).
## Открытые вопросы (второй раунд к Опусу) — ЗАКРЫТЫ
1. **Источник live для zero_count в Delete** → `GetInstanceStateParams` (не TF state). ✅
2. **Sentinel в valueList** → явный `off_value:"no-needed"` в реестре (данные, не логика). ✅
3. **Идемпотентность zero_count** → пропускать `run`, если live уже `count=0` (применимо и к static). ✅
4. **count=0** → канонический inverse; полное удаление ipSpace = отдельный опциональный `Mode:remove` (не подменять zero_count). ✅
## ИТОГ — финальная модель inverse
`delete_params: []{ Code, Mode, Value?, zero_fields?, off_value? }`
- `Mode: static` — обратное значение = `Value` (boolean→"false"; string→off_value).
- `Mode: zero_count` — взять live array-map-fixed, обнулить поля из `zero_fields:["count"]`.
- `Mode: remove` (опц., не для FullPipe) — полное удаление элемента.
Рантайм: Delete → `GetInstanceStateParams` → построить inverse → если live уже целевое → no-op (skip run) → иначе `modify`.
Реестр (yaml-generator main.go):
- `vc_org.ip_space`: `inverse`, param `vIPConfigure` `Mode:zero_count, zero_fields:[count]`.
- `vc_nsxt.network`: `inverse`, params `needEnableAVI`=static `"false"`, `ipSpaceName`=static off_value `"no-needed"`.
Порядок destroy (уже корректен в .tf): `edge_net → org_ips → edge → vdc`.
## Факты (не менять, проверено)
- `count=0` принимается API (minvalue:1 в схеме — не отвергает), идемпотентно (ORG_IP_MODIFIER_TEST_2026-09-22).
- SNAT off = `ipSpaceName="no-needed"` (sentinel в valueList, HAR_SNAT_MODIFY_FINDINGS).
- `needEnableAVI` boolean → inverse `"false"`.
- vc_org.ip_space: `error` → нужен `inverse` (zero_count). vc_nsxt.network: inverse+static `needEnableAVI=false`, добавить `ipSpaceName="no-needed"`.
+264
View File
@@ -0,0 +1,264 @@
# Ответ Opus 4.8: код-ревью и оценка архитектуры (3 задачи roadmap)
Дата: 2026-09-22 | Вопрос: `docs/prompt_for_opus_review.md` (коммит `7eab45e`)
Ответ получен строго по контексту промпта (файлы не открывались).
---
Задание — код-ревью по контексту (файлы открывать запрещено). Отвечаю сжато по пунктам.
## 1. Покрывает ли модель 3 задачи
| Задача | Вердикт | Причина |
|---|---|---|
| vcOrg modify (динамич. IP) | 🟡 | Покрыта, если аллокация = modify-параметр (кол-во/список). Проблема — тип массива (array-map-fixed = JSON-строка) и семантика «растёт по мере необходимости» |
| vcNsxt modify (SNAT + IP из пула vcOrg) | 🔴 | Текущая модель НЕ покрывает: refSvc ссылается на инстанс/uid, а нужен конкретный элемент списка IP. Нужна новая связка (computed-атрибут + ссылка) |
| k8sShturval create | 🟢 | Стандартный CRUD-create, покрыт. Риски — только long-create и типы параметров |
## 2. Как моделировать пул IP vcOrg
- **(б) отдельный ресурс/подресурс на IP** — ✗ противоречит архитектуре: «один сервис = один ресурс», подресурсы/data source не генерируются. Потребует ломки генератора.
- **(а) атрибут-массив на nubes_vc_org** — ✅ согласуется. Но НЕ через array-map-fixed (JSON-строка: нет семантического diff, ломается порядок, нельзя адресовать элемент).
- **Рекомендация:** декларативное желаемое состояние — либо `int64 desired_count` (modify), либо реальный `ListNestedAttribute`; аллоцированные IP отдавать как **типизированный computed** (list объектов), а не плоскую JSON-мапу. Императивная семантика «добавь ещё один» несовместима с декларативным Terraform.
## 3. Как vcNsxt получает IP из vcOrg
| Вариант | Оценка |
|---|---|
| (а) refSvc-параметр | 🔴 не может указать на конкретный элемент списка, только на uid инстанса |
| (б) computed-атрибут vcOrg + ссылка `nubes_vc_org.x.<attr>` | 🟢 лучший фит. IP в nsxt — обычная string, пользователь берёт конкретный элемент |
| (в) data source | ✗ не генерируется |
**Рекомендация — (б).** Условие: computed-вывод vcOrg должен быть **индексируемым типизированным** (`allocated_ips[0]`), не спрятанным в JSON-строке `state_out_flat` — иначе граф зависимостей Terraform не построит ребро и придётся ручной `depends_on`.
## 4. Риски текущего кода для этих потоков
- 🔴 **modify с массивами:** array-map-fixed = JSON-строка → нет семантического diff, смена порядка API даёт ложные modify, «дорастить пул» = полная замена списка (опасно, возможен ре-аллок).
- 🟡 **create-only guard:** если параметры SNAT-IP / аллокации помечены не как `is_modifiable` → ModifyPlan заблокирует задачи 1 и 2. Проверить флаги в YAML.
- 🟡 **read-back:** если API возвращает IP в нестабильном порядке → вечный drift на vcOrg.
- 🟡 **порядок плана:** зависимость nsxt→vcOrg держится, только если ссылаемый IP — first-class computed (unknown на plan). Внутри JSON-строки Terraform зависимость не отследит → apply-time ошибки.
## 5. k8sShturval create — что критично
- refSvc к vdc/org резолвится (display name ИЛИ uid).
- 🔴 long-create: create-op должен **ждать готовности** (poll ready-state), а не только «принято»; следить за context timeout.
- типы параметров: map-fixed (nested) vs array-map-fixed (JSON), корректность required/default.
- read-back state_params после create для computed-полей.
## 6. Итог — рекомендации по приоритету
1. 🔴 Добавить в vcOrg **типизированный индексируемый computed** для аллоцированных IP (list объектов, не JSON-мапа) — разблокирует задачу 2 через ссылку.
2. 🔴 Моделировать аллокацию как **декларативное желаемое состояние** (int64 count или ListNested) с реконсиляцией в modify; параметры пометить `is_modifiable`.
3. 🟡 Заменить array-map-fixed (JSON-строка) на **ListNestedAttribute** — ради адресуемости элементов, семантического diff и стабильного порядка.
4. 🟡 Гарантировать граф зависимостей: ссылаемый IP — отдельный computed-атрибут, не поле JSON, чтобы не требовать `depends_on`.
5. 🟢 k8sShturval: добавить ожидание готовности (poll) в long-create и проверить резолв refSvc.
**Ключевой вывод:** задачи 1 и 3 укладываются в текущую модель с доработками генератора; **задача 2 требует новой абстракции** — типизированного computed-вывода на vcOrg (вариант 3б), т.к. refSvc принципиально не адресует элемент списка.
---
## Раунд 2 — уточняющие вопросы и пересмотр
После проверки кода выяснилось, что промпт раунда 1 описывал модель неполно. Opus получил уточнённый контекст и 7 доп. вопросов.
### Уточнение контекста (передано Opus)
- Генератор уже поддерживает 4 вида ресурсов: instance (`nubes_<svc>`), subresource, action (только redeploy), modifier (`nubes_<svc>_<modifier>`).
- Modifier-ресурсы для задач 1 и 2 УЖЕ СГЕНЕРИРОВАНЫ:
- `nubes_vc_org_ip_space` (vc_org, modify 207): `vc_org_id` (required), `v_ip_configure` (required, StringAttribute = JSON-строка). Computed-выводов НЕТ.
- `nubes_vc_nsxt_network` (vc_nsxt, modify 111): `vc_nsxt_id` (required), `need_enable_avi` (bool), `virtual_services_count` (int64), `ip_space_name` (string optional), `qos_profile` (string), `routed_net_configuration` (required, JSON-строка). Computed-выводов НЕТ.
- Маппинг: `map-fixed` → SingleNestedAttribute; `array-map-fixed` → StringAttribute (JSON-строка).
- Завершение операции — поллинг до `dtFinish`. Delete modifier — no-op.
- Связка NSXT↔Org идёт **по имени ipSpace**: `vc_org.modify` задаёт `vIPConfigure[].name`, `vc_nsxt.modify` принимает `ipSpaceName` = это имя. Конкретный IP нигде не адресуется.
### Ответ Opus (пересмотр)
## 1. Верен ли прошлый вердикт про «новую абстракцию»
🟢 **Прошлый вывод отменяется.** Он строился на неполной модели. modifier-ресурсы уже существуют → задача 2 реализуема сегодня двумя модификаторами (`nubes_vc_org_ip_space` → `nubes_vc_nsxt_network`), связанными по имени ipSpace. Никакой новой абстракции и ломки генератора не требуется.
## 2. Минимальная доработка генератора для ссылки вместо строки
Добавить **один computed-вывод** на `nubes_vc_org_ip_space` — эхо `v_ip_configure[].name` (имя ipSpace). Тогда:
```
ip_space_name = nubes_vc_org_ip_space.x.ip_space_name
```
Это даёт ребро графа + защиту от опечатки. Адресовать IP не нужно — связка по имени. Опционально сверх минимума: валидация, что имя существует в org (refSvc-подобная проверка `ip_space_name` по vIPConfigure целевого org).
## 3. SNAT: свободная строка или computed обязателен
- Функционально **свободная строка + `depends_on` работает** (связка по имени, IP не адресуется). 🟡 Риски: опечатка, нет автоматического ребра графа без ручного depends_on, нет drift-детекта.
- **Экспортировать имя (string), НЕ список IP.** Список IP не нужен — привязка идёт по имени ipSpace, не по конкретному адресу.
- Вердикт: не строго обязателен для функции, но настоятельно рекомендован для корректности графа. Минимум — computed `ip_space_name` (string).
## 4. v_ip_configure как JSON-строка — реально ли бьёт
🟢 **В этом сценарии практически безвредно.** Значение фактически write-only (computed read-back нет), потребителю нужно только имя, которое пользователь сам задал. Поэлементное сравнение/адресация не требуются → прошлый 🔴 понижается.
Остаточный 🟡: спурьёзный diff при переформатировании JSON пользователем; если count растёт со временем — семантика «полная замена списка» без гранулярного diff может ре-триггерить modify/ре-аллокацию. Но без read-back и при связке по имени это не блокер.
## 5. Delete modifier = no-op
🟡 **No-op допустим** для association/one-shot модификатора, если обратного payload нет — это известный паттерн.
**Реальные последствия destroy:** Terraform удалит запись из state, но бэкенд НЕ освободит IP и НЕ отключит SNAT → утечка ресурсов + расхождение state↔бэкенд.
- Если бэкенд поддерживает обратную операцию (де-аллокация / disable SNAT) — no-op это реальный пробел 🔴, надо подключить reverse-op.
- НЕДОСТАТОЧНО ДАННЫХ: есть ли у vc_org/vc_nsxt обратный payload. Если его действительно нет — no-op единственный вариант, но обязательно задокументировать поведение destroy.
## 6. Нужен ли ещё «int64 desired_count на инстансе»
🟢 **Нет, прошлый п.2 отменяется.** modifier — правильная единица для чередующейся зависимости (org аллоцирует → nsxt потребляет → org доаллоцирует). Счётчик на инстанс-ресурсе связал бы жизненный цикл пула с жизненным циклом инстанса и сломал бы взаимное чередование org↔nsxt. **Отдельный modifier строго лучше.**
## 7. k8sShturval create — что остаётся критичным
Помимо резолва refSvc (display name ИЛИ uid) и корректности required/default в map-fixed/array-map-fixed:
- 🟡 **read-back computed после create** (kubeconfig/vault-выводы) — что реально возвращается в state.
- 🟡 **обработка failure-состояния в поллинге**: dtFinish должен различать «готово успешно» и «завершилось с ошибкой», иначе провал маскируется под успех. НЕДОСТАТОЧНО ДАННЫХ по логике ошибки в poll-цикле.
- 🟢 timeout-бюджет long-create vs интервал поллинга.
---
**Итог пересмотра:** обе задачи 1 и 2 **укладываются в существующий modifier-паттерн без новых абстракций**. Единственная стоящая доработка — добавить computed-экспорт `ip_space_name` на org-модификатор (п.2/3), чтобы заменить свободную строку ссылкой и построить граф зависимостей. Проблема JSON-строки в этом сценарии не критична (п.4). Главный остаточный риск — no-op delete и утечка IP/SNAT при destroy (п.5), требует проверки наличия обратного payload.
---
## Раунд 3 — дрейф сгенерированного кода и защитный механизм
Отдельная тема (не roadmap): расхождение `generated/{stand}/go` ↔ `provider/internal/resources_gen`, как предотвратить и сделать видимым.
### Контекст, переданный Opus
Go-провайдер Terraform. Ресурсы генерируются из YAML-спеков генератором.
Цепочка: YAML → generated/{stand}/go/*.go → (копирование) → provider/internal/resources_gen/*.go → сборка бинаря.
Факты:
- generated/ и provider/internal/resources_gen/ внесены в .gitignore → дрейф между ними НЕ виден в git status/diff.
- Реальный дрейф уже случился: копия в provider/ устарела (сгенерирована до двух последних фиксов генератора).
- Есть два скрипта сборки: (а) правильный — копирует свежий generated во временный каталог provider и собирает оттуда; (б) рисковый — собирает напрямую из provider/ (берёт потенциально устаревшую локальную копию).
Вопросы: 1) коммитить автоген или .gitignore+проверка; 2) два пути сборки — корень ли; 3) что предотвращает дрейф, а не детектит; 4) убрать вторую копию и генерировать при сборке; 5) как гарантированно «видно сразу при изменении кода».
### Ответ Opus
## 1. Коммитить сгенерированный код или .gitignore + проверка дрейфа
🟢 **Коммитить сгенерированный код** (для соло/малой команды это надёжнее). Обоснование:
- Расхождение всплывает в `git diff`/PR-ревью — там, куда уже смотришь, а не в отдельной проверке, которую надо не забыть запустить.
- CI-гейт `regenerate && git diff --exit-code` превращает дрейф в жёсткую ошибку сборки.
- Минус — шум в диффах от больших автогенов, но это приемлемая цена за видимость.
.gitignore + внешняя проверка дрейфа 🟡 — работает, только если проверку невозможно обойти; сейчас её обходят вторым скриптом. Скрытый код = скрытый дрейф (уже случилось).
## 2. Два пути сборки — корень проблемы?
🔴 **Да, это корень.** Проблема не в дрейфе как таковом, а в том, что **существует путь, собирающий из потенциально устаревшей копии**. Пока рисковый скрипт (б) доступен, дрейф рано или поздно попадёт в бинарь.
**Оставить ровно один канонический путь.** Скрипт (б) удалить или сделать так, чтобы он физически не мог собрать из несвежей копии (см. п.3–4).
## 3. Что ПРЕДОТВРАЩАЕТ дрейф, а не только детектит
| Механизм | Роль |
|---|---|
| Жёсткий fail на старте сборки | 🟡 **детект** — не даёт собрать плохой бинарь, но дрейф уже есть |
| Авто-синхронизация/генерация перед сборкой | 🟢 **предотвращение** — копия физически не может быть устаревшей |
| git pre-commit hook | 🟡 **детект** на этапе коммита |
**Предотвращает только генерация/синк как обязательная часть сборки** (копия всегда свежая). Fail и hook — детекторы. Рекомендуемая комбинация: **генерация-при-сборке (предотвращение) + CI `git diff --exit-code` после регенерации (страховка от обхода)**.
## 4. Более сильный паттерн: убрать вторую копию, генерировать при сборке
🟢 **Да, это сильнейший вариант.** Нет постоянной копии → нечему рассинхронизироваться. Единственный источник истины = YAML + генератор (`go generate` / шаг Makefile перед `go build`).
Минусы:
- 🟡 Сборка зависит от наличия и детерминированности генератора (версию генератора надо пинить).
- 🟡 Медленнее сборка.
- 🔴 Провайдер перестаёт быть чистым Go-модулем: нельзя `go get`/собрать без тулчейна генератора; воспроизводимость завязана на версию генератора.
- Сложнее просто «посмотреть» сгенерированный код без сборки.
- НЕДОСТАТОЧНО ДАННЫХ: детерминирован ли генератор (стабильный порядок полей/файлов) — без этого diff-гейт будет шуметь.
**Компромисс:** генерировать при сборке И коммитить результат (генерация переписывает закоммиченную копию, CI ловит расхождение) — совмещает «невозможно устареть» с «видно в диффе».
## 5. «Видно сразу при изменении кода, не закопано в доках»
Гарантированно это даёт **только закоммиченный сгенерированный код**: расхождение появляется в `git diff` того же PR, где меняется генератор/YAML. Усилить:
- **pre-commit hook** `regenerate` → расхождение всплывает до коммита;
- **CI-гейт** `regenerate && git diff --exit-code` → нельзя смёржить с дрейфом.
Вариант «генерировать при сборке без копии» даёт *другую* гарантию — «устареть невозможно», но там **нет диффа для обзора**. Если требование именно «видно в изменениях кода» — нужен закоммиченный автоген.
---
**Итог — рекомендация по приоритету:**
1. 🔴 Убрать рисковый путь сборки (б); оставить один канонический.
2. 🟢 Сделать генерацию/синк обязательным шагом канонической сборки (предотвращение).
3. 🟢 Закоммитить сгенерированный код + CI-гейт `regenerate && git diff --exit-code` (видимость + защита от обхода).
4. 🟡 Запинить версию генератора и обеспечить детерминированный вывод, иначе гейт будет ложно срабатывать.
---
## Раунд 4 — универсальность и реестр исключений
Тема: принцип «универсально по умолчанию, исключения — только если API не ложится», и как кодировать исключения, чтобы они были однозначно понимаемы при каждой генерации.
### Контекст, переданный Opus (кратко)
Пайплайн: YAML-спека сервиса (из API) → универсальный генератор → Go-ресурсы → сборка. Код/скрипты/логика генерации — одни для всех стендов и сервисов; различается только YAML. Механизм modifier-ресурсов уже универсален (дженерик читает `kind: modifier` + имя). Не универсально сейчас: хардкод в yaml-generator (`svc.ID == 19 → ip_space`, `svc.ID == 22 → network`) и в docs-generator (`ServiceID == 90`, спец-вывод postgres). Отдельно был инцидент дрейфа git-ignored сгенерированного кода (фон).
Выработанный принцип (просили оценить): «Универсально по умолчанию. Исключение — только если API реально не ложится. Каждое исключение — одна явная таблица/структура, которую КОД ЧИТАЕТ, с полями ЧТО/ЧТО ДЕЛАЕТ/ПОЧЕМУ/ГДЕ. Канонический список — в ARCHITECTURE-доке, код ссылается. Изменение = изменение таблицы → видно в диффе.»
### Ответ Opus
## 1. Оценка принципа
🟢 **Принцип корректен долгосрочно** — «convention over configuration + escape hatch».
Подводные камни:
- 🟡 «API не ложится» субъективно → нужен объективный тест-триггер, иначе exception creep.
- 🔴 Исключения не возвращаются в ядро: когда паттерн повторился 2–3 раза, нужен ритуал «промоушена» в ядро.
- 🟡 Обратный перекос: обобщать реально одноразовый случай — раздувает ядро.
## 2. Как кодировать исключения
| Вариант | Видимость в diff | Нельзя «проспать» | Поддержка | Рассинхрон с YAML |
|---|---|---|---|---|
| (а) именованная таблица в коде, код её читает | 🟢 | 🟢 (если итерирует и падает на неучтённом) | 🟡 нужна пересборка | 🟢 низкий |
| (б) отдельный yaml-конфиг | 🟢 | 🟡 легко забыть подключить | 🟢 без пересборки | 🟡 средний |
| (в) аннотации в YAML-спеке | 🔴 | 🔴 | 🔴 | 🔴 фатально: YAML регенерится → аннотации затираются |
**(в) отклонить.** **Рекомендация: (а)** — именованная структура, которую код итерирует и ассертит.
## 3. Граница «логика» vs «данные»
- В ядре — механизм/алгоритм (как модификатор генерится, маппинг схемы). Никогда не per-service.
- В реестре — чистые данные («сервис X → имя модификатора Y», «сервис 90 → набор полей Z»).
Признаки: 1) `if id == N`, меняющий поток исполнения → извлечь данные; 2) убрать пункт → меняются только значения, не поведение → данные; 3) copy-paste кода → механизм (обобщать), разные строки таблицы → данные.
## 4. Паттерн «override registry» в кодогенераторах
- tfplugingen-openapi: `generator_config.yml` отдельно от спеки + IR (`terraform-plugin-codegen-spec`).
- OpenAPI Generator: vendor extensions `x-*` + template-оверрайды + config-json.
- protoc-плагины: custom options (напр. `google.api.http`).
Что перенять: 1) отдельный версионируемый конфиг оверрайдов; 2) IR-слой; 3) fail на неучтённом; 4) стабильное символьное имя, не сырой ID.
## 5. Не противоречит ли реестр «YAML — единственный источник»
Не противоречит — при разделении двух доменов истины:
- **API-YAML** = истина про «что есть сервис» (машинно-владеемый, регенерится).
- **Реестр оверрайдов** = истина про «наши провайдер-специфичные решения» (человеко-владеемый).
Теневой источник — только если ОДИН факт лежит в обоих. 🔴 Нельзя аннотировать API-YAML. Конвейер: `API-YAML + overrides → merged IR → codegen`.
## 6. Риски «таблица + раздел в ARCHITECTURE.md»
- 🔴 ARCHITECTURE.md дрейфует от таблицы → возврат к `if id==19`, если кто-то добавит ветку в обход.
- 🔴 Числовые ID (19/22/90) непрозрачны.
Как закрыть: 1) 🔴 единая точка маршрутизации + CI-lint/grep-гейт против `svc.ID ==` вне реестра; 2) раздел ARCHITECTURE генерировать ИЗ реестра (golden-test); 3) 4 поля — поля структуры, а не комментарии; 4) стабильные символьные ключи; 5) fail-fast: генератор падает, если спец-обработка без записи в реестре.
---
**Итог:** сильнейшая реализация — отдельный человеко-владеемый override-реестр (данные, не логика), стабильные ключи, IR-слой применения, CI-гейт против хардкодов вне реестра, раздел ARCHITECTURE генерируется из реестра. Это устраняет и `if id==N`, и дрейф доки.
@@ -0,0 +1,89 @@
# Резюме сессии: Диагностика K8s (Штурвал), VDC и архитектура провайдера (2026-09-20)
## 1. Инфраструктурный контекст
- **ВМ 213 (jump-host / dev)**:
- SSH: `ssh vps` (`5.172.178.213`, user `naeel`, key `~/.ssh/naeel_vm_id_ed25519`).
- kubectl контекст по умолчанию: `tazetdinovn@gmail.com@naeel-test-3`.
- **Кластер `naeel-test-3`**:
- API: `https://185.247.187.146:6443`.
- Узлы: 6 нод (3 control-plane, 3 workers), версия v1.34.1 / платформа Штурвал 2.12.1.
- **Кластер `devclustername`**:
- API: `https://185.247.187.226:6443`.
- Статус: порт 6443 на Edge доступен, но TLS сбрасывается (`connection reset by peer`) — виртуальные машины кластера находятся в `suspend` / выключены.
---
## 2. Что было сделано и починено в кластере `naeel-test-3`
### Проблема:
В веб-интерфейсе Штурвала кластер висел в статусе **«Работает с ошибками» (⚠️ 2/4 по NodeConfigItems)**.
### Причина:
1. На активном воркере `naeel-test-3-workers-5p8w7-vxzch` висело ожидание применения конфигураций (`RebootPending` с типом `drainonly`).
2. Очередь на применение/drain была заблокирована (`SlotsOccupied`), так как в CRD `nodeconfigs.node.shturval.tech` осталась старая удалённая нода `naeel-test-3-workers-5p8w7-nqkr2` с зависшим флагом `rebootallowed: true`.
### Решение:
1. Вычищены все фантомные объекты `nodeconfigs`, которых уже нет среди реальных K8s-нод (сняты блокирующие finalizers).
2. Слот освободился: контроллер `shturval-node-config` корректно выполнил `drainonly` на воркере `vxzch` (под `pythonk8s` переехал на соседний воркер `wwqj2`).
3. Применились оставшиеся `NodeConfigItem` (`generic-init-config`, `all-to-nubes-registry`).
4. **Текущий статус**:
- Все 6 нод в статусе `Ready`.
- Все 6 `nodeconfigs` в статусе `READY: true`.
- Все 4 `nodeconfigitems` в статусе `ready: true` (**4/4, статус кластера зелёный**).
- Под `pythonk8s` (`drhider.pythonk8s.dev.nubes.ru`, ns `20a75175-a58c-49cb-b8fa-e86367b1a8dc`) поднят и работает (1/1 Running).
- Потребление ресурсов: среднее ~22m CPU и ~103 MiB RAM на под; суммарно на весь 6-узловой кластер ~1.6 CPU и ~7.5 GiB RAM (минимальный фоновый простой).
---
## 3. Блокировка операций в личном кабинете облака (Suspend / Modify)
### Симптом:
При попытке выполнить операцию `suspend` кластера в UI облака возникает ошибка:
`Concurrent operations are not supported (job status: cannot obtain job result) There is a started operation on this instance`
### Диагностика через API Gateway (`lk-api-gateway-dev.ngcloud.ru`):
- Инстанс кластера: `e78a40b9-7de1-4c88-af7a-ad7f7efaa7c2`.
- Зависшая операция: `modify` (`opUid: 2fb7dd59-732a-4bb9-add2-6bddd7329f72`).
- Причина зависания: оркестратор CFS поймал таймаут (`Timeout has been exceeded`), выполнил откат, записал лог ошибки, но **не проставил `dtFinish` и `isSuccessful` в БД**.
- Статус операции в API остался незакрытым, из-за чего API Gateway блокирует любые новые операции над инстансом.
- **Внимание**: это баг бэкенда платформы облака (CFS/оркестратора). Средствами `kubectl` внутри кластера это не лечится — требуется сброс статуса операции на стороне API платформы или через поддержку.
- Ошибка в истории операций: `Не удалось включить кластер. Ошибка: Error not found from 'parseTerraformError'` — вызвана тем, что общий обработчик бэкенда облака по ошибке прогнал текст через парсер ошибок Terraform. Внутри K8s Terraform не используется.
---
## 4. Архитектурные правила по VDC и Terraform-провайдеру
### Почему возникает ошибка дубликата VDC:
`VDC с именем 'WZ03709-saas-snb1-i24-vcpu50' уже существует внутри организации 'WZ03709-saas'`
- Имя VDC генерируется бэкендом детерминированно: `{org}-{type}-{segment}-{cpu}-vcpu{reservation}`.
- В одном сегменте организации существует максимум два класса VDC:
- `vcpu50` (50% гарантия vCPU — для dev/test, оверселлинг, дешевле);
- `vcpu80` (80% гарантия vCPU — для prod/баз данных, жесткая фиксация ресурсов).
- Создавать третий VDC с тем же процентом SLA в рамках одной организации невозможно (конфликт уникальности имен в vCloud) и бессмысленно: изоляция проектов внутри VDC делается через **vApp**, **сети (Org Networks)** и правила фаервола. При нехватке ресурсов VDC масштабируется через `modify`.
- **Канон Terraform**: при работе с уже созданными VDC в манифесте указывать:
```hcl
adopt_existing_on_create = true
```
(согласно `docs/60_strategy/provider_philosophy.md`).
### Зачем нужны дочерние ресурсы-действия (Action / Sub-resources):
Цепочка развертывания vCloud/NSX-T имеет циклические зависимости:
1. `vcOrg -> create`
2. `vcVdc -> create`
3. `vcNsxt -> create` (Edge Gateway)
4. `vcOrg -> modify` (выделение белых IP в организацию, так как появился Edge)
5. `vcNsxt -> modify` (настройка SNAT под конкретный выделенный IP)
6. `k8sShturval -> create` (нодам нужен интернет через SNAT)
Чтобы пользователю не приходилось делать несколько ручных прогонов `terraform apply` с ручным редактированием `.tf` файлов между шагами, в провайдере создаются **дочерние ресурсы-действия**.
- Под капотом провайдера эти дочерние ресурсы транслируются в точечные вызовы **`modify`** над родительскими объектами.
- Это стандартная практика в Terraform (аналог `aws_security_group_rule` для `aws_security_group`).
---
## 5. Что делать дальше в новом чате
1. Если продолжаем работу с провайдером Nubes:
- Проверить статус зависшей операции `2fb7dd59-732a-4bb9-add2-6bddd7329f72` в API Gateway.
- Разрабатывать/тестировать логику дочерних ресурсов (action/sub-resources) и `modify`-пайплайна по стандарту `docs/60_strategy/provider_philosophy.md`.
2. Если требуется проверить второй кластер (`devclustername` / `185.247.187.226`):
- Убедиться, что кластер выведен из suspend в веб-интерфейсе (чтобы поднялся API server на порту 6443).
@@ -0,0 +1,155 @@
# Резюме сессии: VDC create-flow, диалог с Opus и правка fallback (2026-09-21)
## 1. Контекст задачи
- Цель: довести до рабочего состояния создание `nubes_vc_vdc` в `FullPipe`.
- Симптом: при создании VDC провайдер падал на `GET /instanceOperations/{opUid}?fields=cfsParams` с HTTP 500.
- В ходе разбора было подтверждено, что обычные сервисы через тот же провайдер работали, а VDC попадал в отдельную проблемную ветку backend-обработки.
---
## 2. Диагностика проблемы
### 2.1. Что ломалось
- В `provider/internal/core/client.go` create-flow делал `GET /instanceOperations/{opUid}?fields=cfsParams` сразу после создания операции.
- Для VDC этот запрос приводил к ошибке backend’а:
- `Invalid call of the function [getResourceRealmConfig]`
- `Cannot cast Object type [Struct] to a value of type [string]`
- источник ошибки: `/app/api/v1/resources/instance_operation_cfs_param.cfc`
- Причина по отчету: для `vc_vdc` вычислялся динамический `descr` у `storageConfig.name`, и backend падал на `resourceRealm`, который в DEV хранится как `Struct`, а не `string`.
### 2.2. Почему обычные сервисы не ломались
- На обычных сервисах этот `GET` либо не попадал в проблемный backend-код, либо не требовал вычисления `resourceRealm`.
- Для VDC в YAML есть специфическая зависимость:
- `generated/dev/resources_yaml/21_vc_vdc.yaml`
- `storageConfig.name` содержит вычисляемый `descr` с `getResourceRealmConfig(...resourceRealm...)`.
- Для обычных сервисов, например `vapp` и `postgres`, такого вычисляемого `resourceRealm`-контекста нет.
### 2.3. Почему `hasUnresolvedParams` мешал
- Эвристика проверяла **все** строковые параметры, а не только параметры с `ref_svc_id`.
- Для VDC это ломало fallback на обычных литералах вроде:
- `providerVdc = fast-2.8`
- `networkProvider = default`
- Эти значения не UUID и не JSON, но и резолвить их не нужно.
- В результате при падении GET провайдер вместо продолжения переходил в ошибку.
---
## 3. Диалог с Opus
### 3.1. Что просили у Opus
- Проверить только:
- `provider/internal/core/client.go`
- `NOTES/30_analysis/DEBUG_REPORT_VC_VDC_500.md`
- `generated/dev/resources_yaml/21_vc_vdc.yaml`
- `generated/dev/resources_yaml/26_vapp.yaml`
- `generated/dev/resources_yaml/90_postgres.yaml`
- Вопросы к Opus были узкими:
1. почему обычные сервисы работали, а VDC начал падать на `GET ?fields=cfsParams`
2. есть ли в VDC специфическая структура или зависимость, которой нет у обычных сервисов
3. является ли `hasUnresolvedParams` неверной эвристикой именно в этом месте
4. что именно надо исправить
### 3.2. Что ответил Opus по сути
- Root cause — не данные Terraform и не сами строки `fast-2.8` / `default`, а backend-ошибка на `GET /instanceOperations/{opUid}?fields=cfsParams` именно для VDC.
- VDC отличается от обычных сервисов тем, что в его YAML есть динамический `descr` для `storageConfig.name`, который тянет `resourceRealm`.
- `hasUnresolvedParams` была признана лишней и хрупкой эвристикой: она может ломать fallback на обычных строках.
- Итоговое решение Opus: при ошибке GET идти дальше по браузерному flow, без условий по всем строковым параметрам.
### 3.3. Дополнительные уточнения в диалоге
- Был отдельный спор по формулировке про Lucee / ColdFusion backend.
- В итоге было зафиксировано, что этот термин — не отдельная гипотеза, а просто обозначение backend-слоя, который уже фигурировал в отчетах и traceback’ах.
- Opus также подтвердил, что для VDC этот GET нужен только как вспомогательный шаг для `resolveRefSvcParamValues`, а не как обязательный бизнес-этап.
---
## 4. Что изменили в коде
### 4.1. `provider/internal/core/client.go`
- В `CreateGenericInstanceUniversalV6` удалён gate по `hasUnresolvedParams`.
- Теперь логика такая:
- если `GET /instanceOperations/{opUid}?fields=cfsParams` успешен — парсим и резолвим `ref_svc_id`
- если GET падает — просто продолжаем POST’ить параметры, а потом идём в `validate-cfs` и `run`
- Функция `hasUnresolvedParams` удалена полностью.
- После удаления была убрана осиротевшая документационная строка, оставшаяся над `isHexDigit`.
### 4.2. `provider/internal/core/client_test.go`
- Добавлен тест:
- `TestCreateGenericInstanceUniversalV6_ContinuesWhenOpDetailsGETFails`
- Тест моделирует:
- `POST /instances`
- `POST /instanceOperations`
- `GET /instanceOperations/{opUid}?fields=cfsParams` → 500
- `POST /instanceOperationCfsParams`
- `GET /instanceOperations/{opUid}/validate-cfs`
- `POST /instanceOperations/{opUid}/run`
- финальный `GET /instances/{uid}`
- Проверка теста:
- create-flow завершился успешно
- все 7 параметров были отправлены с ожидаемыми значениями
- polling по операции был ровно один раз
---
## 5. Проверка после правки
- `cd /home/naeel/TF/tf_provider/provider && go test ./internal/core` — успешно.
- После ревью был пойман и исправлен только косметический хвост:
- старый комментарий над `isHexDigit`, оставшийся после удаления `hasUnresolvedParams`.
- После этого пакет `internal/core` снова прошёл тесты.
---
## 6. Вывод по итогам сессии
- Проблема была не в обычных сервисах как таковых, а в специфике VDC-данных и backend-пути, который срабатывал на `GET ?fields=cfsParams`.
- `hasUnresolvedParams` была неверной эвристикой именно в create-flow VDC и ломала рабочий fallback.
- Правильное поведение: если GET падает, не гадать по строковым параметрам, а продолжать browser-like flow через POST параметров, validate и run.
---
## 7. Что дальше
- Следующий этап — уже не правка логики, а публикация и стендовая проверка при необходимости.
- DEV-релиз `2.0.3` успешно собран и загружен в registry `nubes-dev` через `TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev`.
- Перед этим уже был подготовлен короткий запрос на ревью для Opus и получен ответ, который подтвердил направление правки.
---
## 8. Отдельный диалог про `organization_uid`, refSvcId и универсальное поведение
### 8.1. Что стало проблемой
- В `vc_vdc` поле `organization_uid` можно передавать как display name (`kontora`), так и как UUID организации.
- В коде `provider/internal/resources_gen/21_vc_vdc_resource.go` это поле сейчас резолвится через `ResolveRefSvcParamValue(...)` в UUID.
- После `apply` Terraform видит расхождение: в конфиге было имя, в state оказался UUID, и появляется ошибка `Provider produced inconsistent result after apply`.
- Параллельно в этом же ресурсе остаются ручные `EqualFold`-хаки, которые пытаются сохранить старое значение, но не решают кейс "имя vs UUID".
### 8.2. Почему это сравнивали с S3
- Для `nubes_s3_bucket` похожее поведение уже работает: ref-поле `s3_user_uid` проходит через общий механизм refSvc-резолва и state-refresh.
- В S3 есть симметричный путь: UUID можно принимать на вход, а состояние при чтении синхронизируется через общий mapping-слой.
- Поэтому S3 не падает на inconsistency, а VDC падает из-за локальных restore-хаков и разного поведения на create/read/update.
### 8.3. Что выяснили по коду
- Ключевой участок VDC:
- [provider/internal/resources_gen/21_vc_vdc_resource.go](provider/internal/resources_gen/21_vc_vdc_resource.go#L197-L202)
- [provider/internal/resources_gen/21_vc_vdc_resource.go](provider/internal/resources_gen/21_vc_vdc_resource.go#L323-L338)
- [provider/internal/resources_gen/21_vc_vdc_resource.go](provider/internal/resources_gen/21_vc_vdc_resource.go#L388-L403)
- [provider/internal/resources_gen/21_vc_vdc_resource.go](provider/internal/resources_gen/21_vc_vdc_resource.go#L511-L525)
- В S3 аналогичный слой устроен аккуратнее:
- [provider/internal/resources_gen/13_s3bucket_resource.go](provider/internal/resources_gen/13_s3bucket_resource.go#L149-L159)
- [provider/internal/resources_core/state_refresh.go](provider/internal/resources_core/state_refresh.go#L82-L96)
- [provider/internal/resources_core/params_ref_mapping.go](provider/internal/resources_core/params_ref_mapping.go#L124-L147)
### 8.4. Что решил сделать дальше
- Пользователю нужен не частный фикс только для VDC, а универсальная схема для всех refSvcId-полей.
- Была сформулирована задача для Opus: определить, какой канон выбрать для state, где делать name→UUID и UUID→display_name, и как убрать ручные `EqualFold`-хаки без поломки S3 и других уже рабочих ресурсов.
- Отдельно зафиксировано требование: ответ Opus нужен короткий, но сам вопрос должен быть подробным и однозначным.
### 8.5. Важный вывод на сейчас
- Универсальное решение пока не внедрено.
- Текущий безопасный путь — сначала получить короткий архитектурный ответ от Opus, а уже потом править генератор и пересобирать ресурсы.
---
## 9. Детерминированная пересборка генератора
- После отдельного разбора `kind: modifier` выяснилось, что падение генерации было эксплуатационным: запускался устаревший бинарник `resource-generator`, а не текущие исходники.
- В `TOOLS/scripts/02_generate_resources_and_docs_v2.sh` убран `mtime`-гард через `find ... -newer`; генераторы теперь всегда собираются заново перед прогоном.
- Это сделано специально, чтобы старый бинарник больше не мог скрыть поддержку новых `kind`-веток в YAML-спеках.
- Дополнительно `TOOLS/resource-generator/bin/` добавлен в ignore, чтобы локальный stale-артефакт не путал следующий запуск.
@@ -0,0 +1,194 @@
# Передача контекста: Terraform-провайдер Nubes
Дата: 2026-09-22 | Версия DEV: 2.0.8 | HEAD: 13beb9c (`release(dev): 2.0.8`)
Документ для старта новой сессии. Прочитать целиком перед любыми действиями.
---
## 1. Правила работы (соблюдать строго)
- **Никаких действий без прямого разрешения.** Правки, сборки, заливки, коммиты, запуск
terraform, запросы в API — только по явной команде оператора.
- **Вопрос в любой форме = только ответ.** Не выполнять действий, не предлагать «а ещё могу».
- **Коммитить после каждой правки**, разбивая по смыслу. Не копить в рабочем дереве.
- **Не расширять область работ.** Формулировка «сделай актуальным везде» не даёт права
на дополнительные шаги.
- **Не догадываться.** Не уверен — сказать прямо и спросить. Причину бага доказывать
фактами (логи, трассировки, содержимое файлов), а не гипотезами.
- Язык ответов — русский.
---
## 2. Проект
- Репозиторий: `/home/naeel/TF/tf_provider`
- Провайдер: `terraform-provider-nubes`, Go, `terraform-plugin-framework v1.8.0`
- **Go-модуль провайдера лежит в `provider/`** (не в корне). `go build ./...` из корня
падает с «directory prefix . does not contain main module».
- Генераторы в `TOOLS/`:
- `yaml-generator` — из API в YAML-спеки;
- `resource-generator` — из YAML в Go (шаблоны `text/template` в
`TOOLS/resource-generator/internal/templates/`);
- `docs-generator` — из YAML в Markdown.
- Пайплайн: `TOOLS/scripts/01_generate_yamls.sh` → `02_generate_resources_and_docs_v2.sh`
→ `03_build_and_upload_provider.sh` (скрипт `03` сам прогоняет `01` и `02`).
---
## 3. Состояние на 2026-09-21 (конец сессии)
- Ветка `master`, **рабочее дерево чистое**, HEAD = `13beb9c` (`release(dev): 2.0.8`).
- Локальные коммиты **в origin не пушились**.
- **DEV-версия: 2.0.8**, залита в
`tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes`,
подпись GPG `CB3A0DF161ECC416` (`tazet@narod.ru`, ключ `secrets/private_key.asc`).
- S3: `prod-s3/nubes-terraform-registry/...`, endpoint `https://s3.msk-1.ngcloud.ru`.
- Namespace: `nubes-dev`, провайдер `nubes`.
- Terraform v1.9.5 локально.
---
## 4. Что починено в этой сессии
| Коммит | Что |
|---|---|
| `7ecd2aa` | детерминированная пересборка генераторов (удалён устаревший бинарь) |
| `2286d34` | destroy-guard в `ModifyPlan` + универсальный refSvc (имя или UUID) |
| `1401003` | release 2.0.4 |
| `d608fba` | FullPipe: `edge.tf`, `storage_config` fast→SATA |
| `92e04da` | TODO-документ по багу docs-generator |
| `bffe3d9` | refSvc-поля без `Computed` (unset = null, а не unknown) |
| `61c7e20` | HISTORY сессии |
| `54b0baa` | release 2.0.5 |
| `af2e10b` | docs-generator: `map-fixed` → `= { ... }` (аргумент, не блок) |
| `7c2cc67` | docs-generator: `array-map-fixed` → `jsonencode([...])` |
| `3ca0752` | docs-generator: строковые дефолты в кавычках |
| `8d405ba` | core: lifecycle-aware подсказки + `supportsSuspend` в сигнатурах |
| `c6715e8` | генератор: `supportsSuspend` в diagnostics; нет `suspend_on_destroy` без suspend |
| `69808bd` | release 2.0.6 |
| `67c4d2f` | `TOOLS/scripts/validate_docs_examples.sh` |
| `14ada09` | ТЗ для Flash по tainted-replace |
| `724f5f7` | **убрана create-time проверка существования из `ModifyPlan`** (ломал tainted-replace и `destroy`) |
| `25080b7` | release 2.0.7 |
| `3c0157a` | **`ShouldBeOptionalComputed`**: read-back параметры без Default → `Optional+Computed` |
| `4b34cc7` | **core: гарантия known** — `unknown → null` для read-back полей |
| `13beb9c` | release 2.0.8 |
---
## 5. Ключевые архитектурные факты (не переоткрывать заново)
1. **Две копии сгенерированного кода.**
- `provider/internal/resources_gen/` — в `.gitignore`, локальный артефакт;
- `generated/dev/go/` — актуальный вывод генератора; именно его компилирует релиз
(скрипт `03` копирует его в temp-копию `provider`).
Проверять надо **`generated/dev/go`**, не `resources_gen`. Проверка не той копии
уже приводила к ложным выводам.
2. **Рецепт проверки сборки без релиза:**
```bash
TMP=$(mktemp -d) && cp -R provider "$TMP/provider" && \
find "$TMP/provider/internal/resources_gen" -maxdepth 1 -type f -name '*.go' -delete && \
cp generated/dev/go/*.go "$TMP/provider/internal/resources_gen/" && \
(cd "$TMP/provider" && go build ./...) && echo BUILD_OK && rm -rf "$TMP"
```
3. **Версия правится в 3 файлах:** `TOOLS/config/dev/profile.env` (`VERSION`),
`DEV_STAND/FullPipe/versions.tf`, `VERSIONS.md`.
Затем коммит `release(dev): X.Y.Z` и
`./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev X.Y.Z`.
4. **Перезаливка той же версии бесполезна** — Terraform не перекачает провайдер.
Всегда бампать версию.
5. `*.tfvars` в `.gitignore` (реальный токен в `terraform.tfvars` безопасен от коммита).
6. **Read-back инвариант.** Провайдер читает параметр обратно из `state_params` инстанса
(`RefreshResourceState` + `InputField`). Такой параметр обязан быть `Optional+Computed`,
если он не `Required` и без `Default` — правило `helpers.ShouldBeOptionalComputed`.
Иначе `plan=null` vs `state=значение` → «Provider produced inconsistent result after
apply».
7. `RefreshResourceState` при отсутствии кода в `state_params` схлопывает `unknown → null`
(иначе «Provider produced invalid result object after apply: ... was unknown»).
8. **`terraform destroy` при tainted-ресурсе** выполняет внутренний обычный plan, который
планирует замену (destroy+create); create-узел замены приходит в `ModifyPlan` с prior
state = null — отличить замену от создания невозможно. Поэтому create-time проверка
существования из `ModifyPlan` убрана (коммит `724f5f7`), проверка осталась в `Create`.
9. У сервиса без операции `suspend` в YAML: `SupportsSuspendDestroy=false` →
`deleteMode := "delete"`, атрибут `suspend_on_destroy` не генерируется, а подсказки в
diagnostics не предлагают adopt (он невозможен).
10. Вложенные параметры (`map-fixed`) — `schema.SingleNestedAttribute`; в HCL это
**аргумент** `= { ... }`, не блок. `array-map-fixed` — `schema.StringAttribute`
(JSON-строка).
11. Логи и артефакты отладки: `/tmp/nubes_find_debug.log` (пишет только Plan-диагностика),
`/tmp/plan_trace.txt`, `/tmp/plan_destroy.txt`, `/tmp/plan_norefresh.txt`.
12. Секреты: `secrets/private_key.asc`, `secrets/public_key.asc`, `secrets/dev.token`.
Не выводить содержимое в чат.
---
## 6. Файлы, которые нужно прочесть
**Обязательно:**
1. `.github/copilot-instructions.md` — жёсткие правила оператора.
2. `VERSIONS.md` — что и когда залито.
3. `HISTORY/2026-09-21_fullpipe_vdc_nsxt_and_refsvc_fixes.md` — журнал предыдущей сессии.
4. `TOOLS/resource-generator/internal/templates/instance.go` — главный шаблон ресурса
инстанса (ModifyPlan / Create / Read / Update / Delete / Schema).
5. `TOOLS/resource-generator/internal/helpers/helpers.go` — `ShouldBeOptionalComputed`,
`ParamDefaultExpr`, `IsNested`, nested-хелперы.
6. `provider/internal/resources_core/state_refresh.go` — read-back и инвариант known.
7. `provider/internal/resources_core/resource_diagnostics_required.go` — create-time
проверки существования/усыновления, `runningConflictHint` / `suspendConflictHint`.
8. `TOOLS/scripts/03_build_and_upload_provider.sh` и `TOOLS/scripts/build-provider.sh` —
релизный пайплайн.
9. `DEV_STAND/FullPipe/` — `versions.tf`, `vdc.tf`, `edge.tf`, `variables.tf`, `outputs.tf`.
10. `docs/TODO/docs_generator_nested_attr_syntax.md` — описание бага docs-generator
(уже исправлен, см. раздел 8).
**По необходимости:**
- `TOOLS/resource-generator/internal/templates/{subresource,modifier,action}.go`
- `TOOLS/docs-generator/internal/writers/writers.go` — `formatParamOrBlock`, `sampleValue`,
`isNestedListParam`
- `provider/internal/resources_core/crud.go` — adopt / suspend / delete
- `docs/prompt_for_flash_fix_tainted_replace.md` — разбор tainted-replace
(реализован в `724f5f7`)
- `TOOLS/scripts/validate_docs_examples.sh` — прогон `terraform validate` по примерам из доков
- Память репозитория: `/memories/repo/registry-versions.md`
---
## 7. Стенд FullPipe (Organization → vDC → Edge)
- Каталог `DEV_STAND/FullPipe`, провайдер берётся из `versions.tf` (сейчас 2.0.8).
- `nubes_vc_vdc.vdc` — `suspend_on_destroy = true`, `adopt_existing_on_create = true`.
- `nubes_vc_nsxt.edge` — `routed_net_configuration` задаётся **через `=`** (объект), не блоком.
- Подхватить новую версию: `terraform init -upgrade`.
- На 2026-09-21 `nubes_vc_nsxt.edge` был **tainted** в state (последствие прошлых
неудачных apply). Убирается `terraform untaint nubes_vc_nsxt.edge`.
---
## 8. Открытые вопросы
1. **`docs/TODO/docs_generator_nested_attr_syntax.md` устарел** — баг исправлен
(`af2e10b`, `7c2cc67`, `3ca0752`), но в файле статус «не исправлено».
2. **Стенд FullPipe не проверен end-to-end на 2.0.8** — нет подтверждённого успешного
`apply` (Organization → vDC → Edge) и `destroy` после фиксов.
3. Полный прогон `terraform validate` по всем примерам из доков (63 сервиса) не делался —
проверен только `vc_nsxt`. Скрипт для прогона готов:
`TOOLS/scripts/validate_docs_examples.sh`.
4. `TOOLS/resource-generator/internal/templates/modifier.go:65` — та же схема `Computed`
без ветки read-back. Для бага «inconsistent result after apply» не критично
(Create/Update модификаторов не читают обратно в state), но при работе с `kind: modifier`
держать в голове.
5. Пуш локальных коммитов в `origin/master` не делался.
@@ -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,224 @@
# CHAT RESUME: IaC-развёртывание Штурвала — состояние на 2026-09-24
> **Кому:** новый чат / новый участник. Читать целиком, это хендовер.
> **Что это:** сводка длинной сессии (2026-09-23/24) по вопросу «как дать клиенту IaC для цепочки Штурвал».
> **Статус:** решение НЕ принято, код НЕ написан. Есть анализ, проверенные факты и развилка.
---
## 0. TL;DR (одним абзацем)
Клиенту нужен **настоящий IaC**: один конфиг + `terraform apply` = вся инфраструктура. Цепочка Штурвала
(`vcOrg → vcVdc → vcNsxt → [modify оргов/эдж] → k8sShturval`) упирается в две операции `modify`, которые
провайдер сейчас выразить не может: в схеме tf-ресурса их параметров нет (схема строится только из `create`).
Ручной ЛК и скрипт **отклонены** — это не IaC. Единственный каноничный путь — **отдельные tf-ресурсы под
модификации** (как сделано в провайдере VMware Cloud Director, прецедент в папке `!/`),
плюс желательно, чтобы платформа отдавала через API выводимые значения (`ipSpaceName`), которые юзер знать не может.
Часть прежних «блокеров» оказалась **ложной** — см. §7, это критично.
---
## 1. Задача
Развернуть Штурвал (k8s) целиком через Terraform, с корректным `apply`/`plan`/`destroy`:
```
vcOrg -> create (в нашем случае орг уже создана вручную и НЕ в state)
vcVdc -> create
vcNsxt -> create (Edge)
------------------------------
vcOrg -> modify (аллокация внешних IP: vIPConfigure)
vcNsxt -> modify (включить SNAT, указать внешний IP из vcOrg: ipSpaceName)
------------------------------
k8sShturval -> create
```
Операции строго последовательны. Проблема: между `nsxt.create` и `shturval.create` стоят два `modify`,
которые в один tf-ресурс не укладываются.
---
## 2. Позиции участников (Telegram 2026-09-23)
| Кто | Позиция |
|---|---|
| **Владимир (наш)** | Пытается сделать всё терраформом. `create` — ок, `modify` — «просто так не получится, надо создавать дополнительные ресурсы-модификаторы». Сомневался: может, руками в ЛК или скриптом. |
| **Георгий** | **Основной запрос клиента — IaC.** Ручной ЛК/скрипт «совсем не подойдёт». Правильно указал порядок: сначала nsxt, потом квота на оргу. |
| **Дмитрий** | Хочет «terraform apply одного ямлика со всей инфрой — и всё». Спрашивал, как это реализовано в провайдере Cloud Director из провайдерской УЗ. |
| **Виталий** | Дал 3 tf-файла (`vmware_org.tf`, `vdc.tf`, `network.tf.tmpl`) — **официальный провайдер Cloud Director**. Ключевое: `ipSpace → providerGateway → providerVdc` (цепочка, которую юзер не знает), «квоту делаем через API на оргу, т.к. эджей много, а орга одна», «при создании параметры подкладываются», «в организации один T0». |
---
## 3. Подтверждённые факты (с источниками)
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`.
3. **`ipSpaceName`** (vc_nsxt, modify id **111**, param id **372**, string, required false) есть **только** в modify.
В `create` (id 10) — `vdcUid`, `needEnableAVI`(340), `virtualServicesCount`(341), `qosProfile`(825), `routedNetConfiguration`(1110).
Файл: `generated/dev/resources_yaml/22_vc_nsxt.yaml`.
4. **`vIPConfigure` — replace-семантика, НЕ накопительная.** Повторный modify с тем же count не задваивает (1→1),
работает вверх/вниз/до 0, `count=0` принимается (несмотря на `minvalue:1`), live читается из `state.params`.
Источник: `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md` (проверено на стенде DEV_STAND/FullPipe, провайдер 2.0.9).
5. **`ipSpaceName` — выводимое значение:** цепочка `providerVdc → providerGateway → ipSpace`, юзер его не знает.
Платформа сейчас «подкладывает» недостающие параметры при создании пустой орги. Список доступных ipSpace
в статической выгрузке (`/instanceOperations/default/{id}`) — пустой, виден только в ЛК/живом инстансе. (Виталий + форензика)
6. **Допущение платформы: в организации один T0/провайдер-шлюз.** Рост T0 отложен, но при нём схема сломается.
7. **Доступ к значению modify-параметра:** live берётся из `GET /instances/{uid}` → `state.params`,
а НЕ из `cfsParams.paramValue` (это сохранённый дефолт формы от прошлых прогонов, см. `dtCreated` в HAR).
8. **Flow modify (HAR `org_enough_.har`):** `POST /instanceOperations {instanceUid, operation:"modify"}` →
`POST /instanceOperationCfsParams {paramValue, instanceOperationUid, svcOperationCfsParamId}` →
`POST /instanceOperations/{opUid}/validate-cfs` → `POST /instanceOperations/{opUid}/run`.
9. **Прецедент (VCD, папка `!/`):** та же цепочка делается **отдельными ресурсами** с `depends_on`:
`vcd_nsxt_alb_settings` (`count = var.alb_enable ? 1 : 0`), `vcd_nsxt_alb_edgegateway_service_engine_group`
(`reserved_virtual_services`), `vcd_network_routed_v2`, `vcd_ip_space_custom_quota` (на оргу).
Приём «включено/выключено» = существование ресурса; **inverse = удаление ресурса**.
10. **Nubes — надстройка над Cloud Director.** Наши `vcOrg`/`vcVdc`/`vcNsxt` создают объекты в VCD.
Провайдер Nubes — обёртка над API Nubes, отдельный от официального `terraform-provider-vcd`.
---
## 4. Почему «просто добавить поле» / «насильно в state» / «скрипт» — не работает
- **Добавить поле в .tf** → падает на `plan`: атрибута нет в схеме (см. §3.1).
- **Terraform не может внутри одного ресурса** сделать «create → через N шагов modify».
Декларативный Update требует **желаемого состояния**; `ipSpaceName` — это включение SNAT, а не значение поля.
- **Вписать в tfstate** нельзя: state валидируется по схеме провайдера, а «записанное» состояние ≠ реальность
(получишь чистый `plan` при сломанной инфраструктуре). `null_resource`/`terraform_data` + `local-exec` даёт
только **факт** выполнения, не состояние.
- **Ручной ЛК / скрипт вне tf** — отклонено: это не IaC (нет версионирования, воспроизводимости, дрейфа, отката).
- **Правка провайдера «по-старому»** (метки `kind: modifier` в YAML + реестр в `yaml-generator`) — **отменённый заход**,
см. §7.
---
## 5. Каноничное решение (что делать)
**Два независимых требования — нужны оба:**
1. **Отдельные tf-ресурсы под `modify`** (наша сторона): e.g. `nubes_org_ip_allocation` (vIPConfigure),
`nubes_nsxt_network` (`needEnableAVI`/`virtualServicesCount`/`ipSpaceName`/`routedNetConfiguration`),
привязка через `depends_on` к орге/эджу.
- `Read` = читать родителя (`state.params`), `Delete` = обратный modify (`count=0` / `needEnableAVI=false` /
`ipSpaceName="no-needed"`), `Create/Update` = `modify` с параметрами.
2. **Доступ к выводимым значениям через API** (сторона платформы): динамические `valueList` + цепочка
`providerVdc → providerGateway → ipSpace`. Иначе юзер подсматривает в ЛК (ручной ввод как временный долг допустим,
но поле надо делать `Optional+Computed`, чтобы позже включить автоподстановку без breaking change).
**Дизайн-требования, заложить сразу:**
- `ip_space_name` → `Optional + Computed`;
- optional селектор шлюза (`t0_id` / `provider_gateway`) — чтобы рост T0 не сломал схему;
- не тащить доменные метки в универсальный YAML (см. §7).
---
## 6. Что можно делать уже сейчас, не дожидаясь платформы
- **`vIPConfigure` (аллокация IP на оргу) — можно делать начисто**: блокеров нет, replace-семантика подтверждена тестом,
Read/Delete выражаются через `state.params` и `count=0`.
- **`needEnableAVI` / `virtualServicesCount`** — выразимы (есть и в create, и в modify).
- **`ipSpaceName`** — единственное, что упирается в платформу; временно — ввод юзером (значение он и так смотрит в ЛК).
- **`routedNetConfiguration`** — есть в create (1110) и modify (1112), выразимо.
**Не решено (требует решения до кода):**
- откуда генератор берёт **список** доменных ресурсов (это не данные API, а доменное знание — то самое место,
где раньше был реестр в `yaml-generator`). Форму выбрать осознанно: явный список vs отдельный вход.
- какие шаги вообще остаются на **провайдерском** (облачном) уровне, а какие отдаются тенанту
(квота ipSpace / ALB — возможно, это уровень облака, и тогда в клиентский tf они не входят).
---
## 7. ⚠️ Исправленные ошибки (НЕ повторять!)
1. **Ложный факт «`vIPConfigure` накопительный».** Был протащен в промпт для Opus как «подтверждённый», из-за чего
Opus построил вывод «накопительный API несовместим с декларативной моделью» и объявил два «блокера»
(Read счётчика, адресное освобождение). **Оба ложны** — тест `ORG_IP_MODIFIER_TEST_2026-09-22.md` доказывает
идемпотентность и работу в обе стороны. Документы исправлены.
2. **Прежняя repo-память (`modifier-gotchas.md`) содержала устаревшие утверждения** (эпоха 09-21/22):
`kind: modifier`, `delete_strategy`, `nubes_vc_nsxt_network`, «у vc_nsxt нет instance-modify»,
«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`.
---
## 8. Карта файлов
**АКТУАЛЬНО (источник истины):**
- `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md` ← этот файл
- `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
- `!/` — прецедент Cloud Director (3 файла `vcd_*`), НЕ наш код
**LEGACY — с баннерами (НЕ источник истины, файлы сохранены для истории):**
⛔ «ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО. ТАК ДЕЛАТЬ НЕЛЬЗЯ» (метки `kind: modifier` в YAML + реестр в `yaml-generator`):
- `PLAN_modifier_redesign.md`
- `docs/60_strategy/modifier_resources_ideology_and_specification.md`
- `NOTES/20_prompts/prompt_for_opus_modifier_architecture_full.md`, `…_architecture_q3.md`, `…_modifier_null_bug.md`, `…_modifier_plan_review.md`, `…_modifiers_review.md`
- `NOTES/20_prompts/prompt_for_opus_modifier_review_2.md`
- `HISTORY/OPUS/2026-09-22_modifier_architecture_project.md`, `…_modifier_null_reset_bug.md`, `…_modifier_plan_review.md`, `…_modifiers_code_review.md`
⚠️ «ЧАСТИЧНО УСТАРЕЛО» (модель отменённого механизма; факты внутри верны и переиспользуются):
- `NOTES/30_analysis/inverse_rollback_analysis_2026-09-23.md`
✅ «АКТУАЛЬНОЕ НАПРАВЛЕНИЕ, НО НЕ РЕАЛИЗОВАНО»:
- `NOTES/20_prompts/prompt_for_opus_modifier_global_architecture.md` (требования: YAML без доменных меток; модификаторы — не ветка генератора)
⚠️ «ПЕРЕКРЫТ»:
- `PLAN_regenerate_providers_0.0.1.md` — версия `0.0.1` объявлена легаси в `PLAN_FLASH_reversion_cleanup.md`
**Статус не определён (не трогал):**
- `NOTES/20_prompts/prompt_for_opus_modifiable_architecture.md` (тема: CreateOnly vs Modifiable — не относится напрямую к отменённому заходу)
> Примечание: `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md` — **актуален** (отчёт по проверке, не план).
**Инфра-контекст:**
- `TOOLS/config/<стенд>/profile.env` → `NUBES_API_ENDPOINT`, `TOKEN_FILE`
- `VERSIONS.md` — залитые версии (DEV на 09-22 = 2.0.13; в `generated/dev/provider_build/` лежат 2.0.17 — расхождение)
---
## 9. Развилка (ждёт решения)
| Вариант | Суть | Вердикт |
|---|---|---|
| **A** | Отдельные tf-ресурсы под modify (+ позже data-source для ipSpace) | канон; реализуемо сейчас для `vIPConfigure` |
| **B** | То же, но значения вводит юзер вручную | приемлемый временный долг при `Optional+Computed` |
| **C** | Ждать новых спеков платформы | часть работ всё равно можно начать сейчас |
| **D** | Ручной ЛК / скрипт вне tf | ❌ отклонено (требование IaC) |
| **E** | Пресеты/дефолтное окружение | снижает боль на старте, IaC не заменяет |
**Не принято:** делать ли `modify`-ресурсы доменными «руками» (и как их перечислять в генераторе) —
вопрос архитектуры; и что из шагов остаётся за облаком.
---
## 10. Открытые вопросы к людям
1. **Георгию/продукту:** какие шаги цепочки — тенантские, а какие — уровень облака (квота ipSpace, ALB)?
От этого зависит объём ресурсов в клиентском tf.
2. **Виталию (платформа):** можете отдавать через API (а) динамические списки значений (`ipSpace`),
(б) цепочку `providerVdc → providerGateway → ipSpace`?
3. **Виталию:** файлы `!/` — ваш инструмент провижининга от провайдерской УЗ или справочный пример?
(в диалоге он сказал только «это провайдер от клауд директора»)
4. **Команде:** откуда генератор берёт список доменных ресурсов-модификаций (форма решения, без меток в YAML).
+27
View File
@@ -0,0 +1,27 @@
# 40_chat_summaries — выжимки и хендоверы из чатов
Резюме длинных сессий: чтобы новый чат/человек вошёл в контекст, не перечитывая переписку.
Формат — по конвенции репозитория: `CHAT_RESUME_<тема>_<дата>.md`.
## Файлы
| Файл | Тема | Статус |
|---|---|---|
| `CHAT_RESUME_IAC_2026-09-24.md` | **Текущая линия: IaC + `modify` (Штурвал).** Задача, позиции участников (Георгий/Дмитрий/Виталий), 10 подтверждённых фактов, почему не работает простое, каноничное решение, развилка A–E, **исправленные ошибки**, карта файлов, открытые вопросы | ✅ **Актуально — начинать отсюда** |
| `CHAT_RESUME_2026-09-22.md` | Предыдущая сессия (дата в имени) | ⚠️ История |
| `CHAT_RESUME_2026-09-21.md` | Предыдущая сессия | ⚠️ История |
| `CHAT_RESUME_2026-09-20.md` | Предыдущая сессия | ⚠️ История |
| `CHAT_RESUME_NEW.md` | Хендовер «для нового чата» (без даты — сложно датировать) | ⚠️ Проверить дату перед использованием |
| `CHAT_RESUME.md` | Первый/базовый хендовер (без даты) | ⚠️ Проверить дату |
| `CHAT_RESUME_PLAN_VM.md` | Хендовер по плану работ на ВМ | ⚠️ Проверить дату |
## Как пользоваться
1. Открыть `CHAT_RESUME_IAC_2026-09-24.md` — это сводка всей линии по IaC.
2. Внутри него есть ссылки на детальные документы (`../30_analysis/*`, `../20_prompts/*`).
3. Устаревшие рестюме **не удалять** — они фиксируют состояние на свою дату (полезно для хронологии).
## Важно про даты
Файлы без даты в имени (`CHAT_RESUME.md`, `_NEW`, `_PLAN_VM`) — потенциальный источник путаницы:
перед использованием проверьте содержание (даты/версии внутри) на актуальность.
+24
View File
@@ -0,0 +1,24 @@
# 60_reference — справочные материалы
Справочники и внешние факты: состояния инстансов, матрицы переходов, списки API, обзор
универсального ядра. Это не планы и не анализы — это «шпаргалки».
## Файлы
| Файл | Что внутри | Статус |
|---|---|---|
| `INSTANCE_STATES.md` | Матрица состояний ресурсов в облаке ngcloud | ✅ Справочник |
| `STATE_TRANSITIONS.md` | Матрица переходов состояний инстансов ngcloud | ✅ Справочник |
| `apis.txt` | Список API. **В самом файле предупреждение:** старые API закрываются, не использовать для генерации; актуальные — `lk-api-gateway*.ngcloud.ru/api/v1/svc` | ⚠️ Читать с учётом шапки файла |
| `ai_universal_provider_gen.md` | Подробный отчёт по универсальному ядру и генератору ресурсов | ⚠️ Проверить актуальность (описывает общее устройство) |
## Где искать более точные данные
| Нужно | Где |
|---|---|
| Endpoint + токен стенда | `../../TOOLS/config/<стенд>/profile.env` → `NUBES_API_ENDPOINT`, `TOKEN_FILE` |
| Спеки сервисов (операции/параметры/ID) | `../../generated/<стенд>/resources_yaml/<id>_<svc>.yaml` |
| Live-ответы API | `../../HAR/*.har` |
| Залитые версии провайдера | `../../VERSIONS.md` |
| Схема нумерации стендов | `../10_plans/PLAN_FLASH_reversion_cleanup.md` |
| Состояния после `modify` (примеры) | `../30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md` |
+67
View File
@@ -0,0 +1,67 @@
# NOTES — рабочие материалы по провайдеру Nubes
Здесь лежат **рабочие материалы**: планы, промпты для LLM, анализы, выжимки из чатов, процессные
и справочные заметки. Это НЕ публикуемая документация пользователя (та — в `docs/`, собирается mkdocs).
> **Главное правило:** файлы с баннером ⛔ в первой строке — **отменённый («ложный») путь**.
> Читать можно, опираться нельзя.
---
## Карта папок
| Папка | Что внутри | Когда идти туда |
|---|---|---|
| [`10_plans/`](10_plans/README.md) | Планы работ: актуальные, перекрытые и отменённые | Понять текущие задачи и их статус |
| [`20_prompts/`](20_prompts/README.md) | Промпты для LLM (Opus/Sol/Sonnet/DeepSeek/Codex) | Понять, что и у кого спрашивали; переиспользовать формулировки |
| [`30_analysis/`](30_analysis/README.md) | Анализы, форензика, отчёты, ответы LLM-ревью, разборы багов | Найти факты и обоснования решений |
| [`40_chat_summaries/`](40_chat_summaries/README.md) | Выжимки-хендоверы из чатов (`CHAT_RESUME_*`) | Быстро войти в контекст прошлых сессий |
| [`60_reference/`](60_reference/README.md) | Справочники: состояния, стадии, API, архитектурный обзор | Уточнить терминологию и внешние факты |
> ❗ **Инструкции (сборка/заливка, добавление сервиса, процессы) вынесены из NOTES в корневую папку
> [`HOW_TO/`](../HOW_TO/README.md)** — там индекс «что нужно → какой файл».
---
## Что осталось в корне и в других местах (НЕ переносилось)
| Путь | Что это | Почему оставлено |
|---|---|---|
| `docs/` | Документация + налоговая структура `00_overview … 90_finance`, `curated/`, `TODO/`, `help/`, `ops/` | Завязана на mkdocs (`mkdocs.yml`, `exclude_docs`) — перенос ломает сборку сайта |
| `HISTORY/` | Архив: записи по датам, `HISTORY/OPUS/`, `HISTORY/SONNET/` | Это канонический архив истории; отменённый заход помечен баннерами внутри |
| `HAR/` | Сырые HAR-дампы (live-запросы к API стенда) | Исходные данные для анализа, менять нельзя |
| `generated/` | Выхлоп генераторов (`resources_yaml`, `go`, `docs`, `provider_build`) | Машинный вывод, не заметки |
| `TOOLS/`, `provider/`, `scripts/`, `gateway/`, `apps/`, `charts/`, `tf_examples/`, `secrets/` | Код и артефакты проекта | Не заметки |
| `! /` | **Прецедент Cloud Director** (`vmware_org.tf`, `vdc.tf`, `network.tf.tmpl`) — чужой tf-код на официальном vcd-провайдере | Оставлено на месте (папка названа «!» специально, чтобы быть на виду) |
| `README.md`, `VERSIONS.md`, `mkdocs.yml` | Точки входа | `README.md` — карта всего проекта; `VERSIONS.md` — источник правды по залитым версиям |
| `TMP/`, `test_push.md`, `demo.jpg` | Временное/непонятное | Не трогал (статус не определён) |
---
## Актуальное состояние (2026-09-24)
Если нужен контекст по задаче «IaC + модификаторы (modify)», читать в таком порядке:
1. [`40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`](40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md) — **начинать отсюда**: полный хендовер (задача, факты, позиции участников, развилка, исправленные ошибки).
2. [`30_analysis/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md`](30_analysis/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md) — анализ проблемы и варианты.
3. [`30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md`](30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md) — ответ Opus **с поправками** (часть его «блокеров» ложная).
4. [`30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md`](30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md) — единственные проверенные на стенде факты.
5. [`20_prompts/prompt_for_opus_iac_shturval_modify.md`](20_prompts/prompt_for_opus_iac_shturval_modify.md) — актуальный промпт.
---
## Статусы файлов (что можно использовать)
- ✅ **Актуально** — можно опираться.
- ⚠️ **Перекрыто / частично** — использовать осторожно, есть более новый документ.
- ⛔ **Отменённый путь** — только как история (баннер в файле).
Точные статусы по каждому файлу — в README соответствующей папки.
---
## ⚠️ Известная проблема: устаревшие ссылки
Файлы переносились из корня и `docs/`, поэтому **внутри исторических документов могли остаться
старые пути** (например `docs/prompts/...` или `docs/CHAT_RESUME_...`). В актуальных документах
ссылки обновлены; в архивных — могли не обновляться: ищите файл по имени, а не по пути.
+108 -113
View File
@@ -1,135 +1,130 @@
# DevOps Runbook: Provider Build Pipeline
# Terraform Provider Nubes — карта проекта
This repo root contains the 4 scripts for the full provider build pipeline.
Репозиторий содержит **Terraform-провайдер Nubes Cloud** и всю обвязку вокруг него:
генераторы (API → YAML → Go), пайплайн сборки/заливки, документацию, стенды и служебные материалы.
## Overview
Облачная платформа — **VMware Cloud Director**; сервисы Nubes (`vcOrg`, `vcVdc`, `vcNsxt`, `k8s…`)
создают объекты в ней. Провайдер генерируется из спецификаций API, а не пишется руками.
1) Generate YAML specs from API
2) Generate Go resources + documentation files from YAML
3) Build and upload provider binaries for 3 OS targets
4) Build and publish documentation site
---
## Documentation publishing instructions
## 🚦 Быстрая навигация
The verified documentation generation and publishing pipeline is documented in
[`HISTORY/2026-09-03_docs_upload_pipeline_verified.md`](HISTORY/2026-09-03_docs_upload_pipeline_verified.md).
It covers the generated docs source, MkDocs build, the separate documentation
S3 bucket, VM upload and mirror steps, stand-specific URLs, and the legacy
script that must not be used.
| Что нужно | Куда идти |
|---|---|
| **Инструкции: сборка, заливка, добавление сервиса** | **[`HOW_TO/`](HOW_TO/README.md)** ← начинать отсюда |
| Рабочие материалы: планы, промпты, анализы, выжимки чатов | [`NOTES/`](NOTES/README.md) |
| Пользовательская документация (mkdocs) | [`docs/`](docs/README.md) |
| Архив по датам и разборам | [`HISTORY/`](HISTORY/) |
| Пайплайн публикации документации | [`DOCS_PIPELINE/README.md`](DOCS_PIPELINE/README.md) |
| Залитые версии провайдера (источник правды) | [`VERSIONS.md`](VERSIONS.md) |
| Правила генерации кода (обязательны для генератора) | [`TOOLS/ARCHITECTURE.md`](TOOLS/ARCHITECTURE.md) |
| Правила работы для агента | [`.github/copilot-instructions.md`](.github/copilot-instructions.md) |
| Текущая задача (IaC + `modify`) | [`NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`](NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md) |
## Prerequisites
---
- Go 1.22+
- `python3`
- `gpg`
- `mc` (MinIO/S3 client)
- Docker (for mkdocs build)
## Как собрать и залить провайдер (кратко)
## Shared settings
S3 environment:
- `S3_ENDPOINT` (example: `https://s3.msk-1.ngcloud.ru`)
- `S3_ACCESS_KEY`
- `S3_SECRET_KEY`
Provider naming defaults:
- `REGISTRY_HOSTNAME`: `tf-registry.containerk8s.services.ngcloud.ru`
- `NAMESPACE`: `nubes`
- `NAME`: `nubes`
## Step 1: Generate YAMLs from API
Script: `01_generate_yamls.sh`
Input list of services:
- `services_list.txt` (service_id only)
Token options:
- `TOKEN_FILE=/home/naeel/terra/HH-MM-SS.token`, or
- `NUBES_API_TOKEN` directly
Example:
```bash
export TOKEN_FILE=/home/naeel/terra/08-33-41.token
./01_generate_yamls.sh
cd /home/naeel/TF/tf_provider
# 1) YAML-спеки из API стенда
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev
# 2) YAML → Go-ресурсы + документация
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev
# 3) Сборка (linux/windows/darwin) + GPG-подпись + заливка в S3
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.18
# 4) (опционально) публикация документации
./TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/dev 2.0.18
```
## Step 2: Generate Go resources and docs
**Подробные инструкции:**
[`HOW_TO/HOWTO-UPLOAD.md`](HOW_TO/HOWTO-UPLOAD.md) (сборка/заливка) ·
[`HOW_TO/DEVOPS_BUILD_PIPELINE.md`](HOW_TO/DEVOPS_BUILD_PIPELINE.md) (полный ранбук + GPG-bootstrap) ·
[`HOW_TO/HOWTO_ADD_NEW_SERVICE.md`](HOW_TO/HOWTO_ADD_NEW_SERVICE.md) (новый сервис).
Script: `02_generate_resources_and_docs.sh`
**Схема версий (жёстко):** `prod = 1.*`, `dev = 2.*`, `test = 3.*`.
Легаси (`prod=2.*`, `dev=3.*`, `test=5.*`, `0.0.1`) — не использовать.
Example:
```bash
./02_generate_resources_and_docs.sh
---
## Структура репозитория
| Путь | Что это |
|---|---|
| **`HOW_TO/`** | **Все общие инструкции:** сборка/заливка, пайплайн, добавление сервиса, миграция, генерация доков. Индекс: `HOW_TO/README.md` |
| **`NOTES/`** | Рабочие материалы: `10_plans`, `20_prompts`, `30_analysis`, `40_chat_summaries`, `60_reference`. Карта: `NOTES/README.md` |
| **`docs/`** | Документация: публикуемый сайт (mkdocs) + внутренние разделы (см. ниже) |
| **`DOCS_PIPELINE/`** | Пайплайн публикации сайта документации (`publish-docs.sh`) |
| **`HISTORY/`** | Архив по датам (`HISTORY/OPUS/`, `HISTORY/SONNET/`) — хроника решений и разборов |
| **`TOOLS/`** | Генераторы и скрипты: `yaml-generator` (API→YAML), `resource-generator` (YAML→Go), `docs-generator`, `scripts/`, `config/`, `lib/`. Правила: `TOOLS/ARCHITECTURE.md` |
| **`provider/`** | Исходники самого провайдера (ядро, CRUD-хелперы, `main.go`) |
| **`generated/`** | Машинный выхлоп: `generated/<стенд>/resources_yaml`, `/go`, `/docs`, `/provider_build` |
| **`DEV_STAND/`, `TEST_STAND/`, `PROD_STAND/`** | Terraform-манифесты стендов (проверочные конфигурации, `sync.sh`) |
| **`HAR/`** | HAR-дампы live-запросов к API (сырые данные для анализа) |
| **`secrets/`** | Токены стендов, GPG-ключи, S3-креды. **Не коммитить** |
| **`gateway/`, `apps/`, `charts/`** | Вспомогательный сервис/приложения/чарты (вне ядра провайдера) |
| **`tf_examples/`, `tfflaskcrud/`, `tfluceecrud/`, `tfnodejscrud/`** | Примеры конфигураций Terraform |
| **`! /`** | Прецедент Cloud Director (чужой tf-код на официальном `terraform-provider-vcd`) — образец «как надо» |
| **`TMP/`** | Временное/бэкапы |
| `mkdocs.yml`, `site/`, `site_test/` | Конфиг и вывод сборки сайта документации |
---
## Документация: `docs/`
Собирается mkdocs (`mkdocs.yml`, nav → `docs/`). Разделы:
| Раздел | Что внутри |
|---|---|
| `docs/index.md`, `docs/30_registry/` | **Публикуется**: главная, справочник ресурсов, руководства, ассеты |
| `docs/curated/` | Проверенные примеры (напр. `postgres/pg_user_db.md`) |
| `docs/90_finance/` | Финансовые шаблоны (акт) |
| `docs/ops/` | Операционные runbook'и: `API_TOKENS.md`, `STANDS.md`, `MONITORING.md`, `ROLLBACK.md`, `RUNBOOK.md`, `TESTING.md` |
| `docs/help/` | Внутренние справки: `BUILD.md`, `build-and-publish.md`, `architecture-and-methods.md`, `error-knowledge-base.md`, `dev-reference/` |
| `docs/00_overview/`, `docs/20_discovery/`, `docs/40_analysis/`, `docs/50_history/`, `docs/60_strategy/`, `docs/70_api/` | Внутренние разделы (исключены из сайта: `exclude_docs` в `mkdocs.yml`) |
| `docs/TODO/` | Технические заметки «что не сделано» |
---
## Пайплайн (что происходит под капотом)
```
API стенда ──01──▶ generated/<стенд>/resources_yaml/*.yaml (yaml-generator)
│
├──02──▶ generated/<стенд>/go/*.go (resource-generator + registry)
│ generated/<стенд>/docs/*.md (docs-generator)
│
├──03──▶ сборка linux/windows/darwin → GPG-подпись → S3-реестр
└──04──▶ mkdocs build → публикация документации
```
Outputs:
- Go files in `universal_rebuild/internal/resources_gen`
- Docs in `docs/30_registry/resources`
Ключевое: **схема tf-ресурса строится из операции `create`** в YAML, а ID операций/параметров
сохраняются из API. Нюансы и известные ограничения — в `NOTES/30_analysis/` и `NOTES/README.md`.
## Step 3: Build and upload provider
---
Script: `03_build_and_upload_provider.sh`
## Стенды и реестр
Uses `registry-server-build/build-provider.sh` and signs with:
- `secrets/private_key.asc` (ignored by git)
| Стенд | Namespace | Диапазон версий | Профиль |
|---|---|---|---|
| PROD | `nubes` | `1.*` | `TOOLS/config/prod` |
| DEV | `nubes-dev` | `2.*` | `TOOLS/config/dev` |
| TEST | `nubes-test` | `3.*` | `TOOLS/config/test` |
Example:
```bash
export S3_ENDPOINT=https://s3.msk-1.ngcloud.ru
export S3_ACCESS_KEY=...
export S3_SECRET_KEY=...
./03_build_and_upload_provider.sh 2.0.2
```
- Реестр: `tf-registry.containerk8s.services.ngcloud.ru`; бакет бинарников `nubes-terraform-registry`.
- Общий конфиг реестра: `TOOLS/config/registry.env`; стенд-специфика: `TOOLS/config/<стенд>/profile.env`
(`NUBES_API_ENDPOINT`, `TOKEN_FILE`, `NAMESPACE`, `VERSION`).
- Список сервисов для генерации: `TOOLS/config/services_list.txt`.
## Step 4: Build and publish docs
---
Script: `04_build_and_publish_docs.sh`
## ⛔ Чего не делать
Example:
```bash
export S3_ENDPOINT=https://s3.msk-1.ngcloud.ru
export S3_ACCESS_KEY=...
export S3_SECRET_KEY=...
./04_build_and_publish_docs.sh 2.0.2
```
## Notes
- The GPG private key must remain stable across releases. Do not regenerate per build.
- If the key is regenerated, the registry server must be updated to serve the new public key.
- Terraform will fail with `authentication signature from unknown issuer` if the registry public key does not match the signing key.
- `services_list.txt` is the source of truth for which services are generated.
- If the provider version changes, update `universal_rebuild/main.go`.
## One-time GPG bootstrap (do this once, keep the key stable)
1) Generate and export keys (no passphrase):
```bash
GPG_DIR=${ROOT_DIR}/secrets
GNUPGHOME=$(mktemp -d)
cat > /tmp/gpg_batch <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Subkey-Type: RSA
Subkey-Length: 4096
Name-Real: tazet@narod.ru
Name-Email: tazet@narod.ru
Expire-Date: 0
EOF
gpg --batch --homedir "$GNUPGHOME" --gen-key /tmp/gpg_batch
gpg --batch --homedir "$GNUPGHOME" --armor --export-secret-keys > "$GPG_DIR/private_key.asc"
gpg --batch --homedir "$GNUPGHOME" --armor --export > "$GPG_DIR/public_key.asc"
rm -rf "$GNUPGHOME" /tmp/gpg_batch
```
2) Update registry server public key (ASCII Armor) in:
- `registry-server-build/main.go`
- `operator/cmd/registry/main.go`
3) Rebuild and redeploy the registry server (see `docs/50_history/00_system_mechanics.md`).
4) Build and upload provider artifacts as usual.
# check string
- **Не использовать** легаси-схемы версий (`prod=2.*`, `dev=3.*`, `test=5.*`, `0.0.1`).
- **Не использовать** закрытые API и хосты: `index.cfm`, `registry.kube5s.ru`, `deck-api.ngcloud.ru`.
- **Не вызывать** старые бинарники из `TOOLS/*/bin/` — скрипты пересобирают генераторы сами.
- **Не перегенерировать GPG-ключ** подписи — сломается `terraform init` у пользователей.
- **Не путать бакеты:** бинарники `nubes-terraform-registry`, документация `terraform-registry`.
- **Не опираться** на файлы с баннером ⛔ в `NOTES/` и `HISTORY/` — это отменённые («ложные») пути.
@@ -0,0 +1,200 @@
name: vc_nsxt
service_id: 22
service_display_name: Сетевой шлюз периметра (Edge)
service_short_name: vc_nsxt
service_man: '# Инструкция по развертыванию Сетевой шлюз периметра (Edge) через платформу<br/><br/>## 1. Общая информация<br/><br/>Сервис **Сетевой шлюз периметра (Edge)** предназначен для создания периметрового сетевого шлюза в рамках одного виртуального датацентра (vDC) или группы виртуальных датацентров (groupvDC). <br/>При создании автоматически разворачивается routed-сеть с адресным пространством `10.10.102.0/24`. <br/>Сервис обеспечивает сетевую изоляцию, маршрутизацию, а также может включать функциональность балансировщика нагрузки AVI (ALB) для последующей интеграции, включая поддержку кластеров Штурвал.<br/><br/>### Доступные операции<br/><br/>**create** — Создание нового Edge с привязкой к vDC или groupvDC и развёртыванием routed-сети. <br/>**delete** — Удаление ранее созданного Edge. Недоступно при наличии зависимых услуг (vApp, VM, Кластер Штурвал). <br/>**modify** — Изменение параметров и сетевых настроек существующего Edge.<br/><br/>---<br/><br/>## 2. Параметры развертывания<br/><br/>Ниже приведены параметры операции **create**.<br/><br/>### Тип родительской услуги<br/>Определяет контекст размещения Edge. <br/>Допустимые значения: `vdc`, `groupvdc`. <br/>Использование:<br/>- При выборе `vdc` обязателен параметр **UUID VDC**.<br/>- При выборе `groupvdc` обязателен параметр **UUID Группы VDC**.<br/><br/>### UUID VDC<br/>Идентификатор виртуального датацентра. <br/>Необходимо предварительно создать vDC через услугу «Виртуальный датацентр (vDC)». <br/>Указывается только при выборе родительского типа `vdc`.<br/><br/>### UUID Группы VDC<br/>Идентификатор группы виртуальных датацентров. <br/>Создаётся через услугу «Группа виртуальных датацентров (groupvDC)». <br/>Используется при выборе родительского типа `groupvdc`.<br/><br/>### Включить ALB<br/>Флаг активации AVI Load Balancer в Cloud Director. <br/>Пример значения: `true`/`false`. <br/>Нужен для развёртывания кластера Штурвал и возможности создания Virtual Services.<br/><br/>### Наименование segroup<br/>Имя сервиса групп (segroup) на уровне ресурсной платформы. <br/>Обязательно при включённом ALB. <br/>Определяет группу, в которой будут выделяться пулы AVI для Virtual Services.<br/><br/>### virtualServicesCount<br/>Количество резервируемых виртуальных сервисов (Virtual Services) на AVI. <br/>Типичное значение: 1–10 в зависимости от нагрузки.<br/><br/>---<br/><br/>## 3. Рекомендованные характеристики<br/><br/>### Тестовое окружение (Test)<br/><br/>- Тип родительской услуги: `vdc` <br/>- virtualServicesCount: 1–2 <br/>- ALB: выключен по умолчанию (включать только при необходимости тестирования Штурвала) <br/>- segroup: задаётся только при включённом ALB <br/>- VDC гарантии CPU/RAM: минимальные (поскольку изменить их после создания нельзя)<br/><br/>### Промышленное окружение (Production)<br/><br/>- Тип родительской услуги: `groupvdc` при работе с распределёнными нагрузками или `vdc` для локализованных проектов <br/>- virtualServicesCount: 3–10, исходя из предполагаемого количества публикаций <br/>- ALB: включён, если требуется высокая доступность сервисов или используется кластер Штурвал <br/>- segroup: рекомендуется использовать выделенную группу под проект <br/>- VDC гарантии CPU/RAM: повышенные, с учётом того, что изменить их после создания невозможно без пересоздания vDC<br/><br/>---<br/><br/>## 4. Выходные параметры<br/><br/>### Имя Edge<br/>Уникальное название созданного сетевого шлюза, используется для ссылок и дальнейших операций.<br/><br/>### Имя routed-сети<br/>Автоматически созданная routed-сеть с подсетью `10.10.102.0/24`, используется для подключения ресурсов.<br/><br/>### Параметры Edge<br/>Набор параметров, указанных пользователем при создании: тип услуги, ALB, segroup, virtualServicesCount и др. <br/>Используются в операциях modify и для анализа состояния сервиса.<br/><br/>---<br/><br/>## 5. Дополнительная информация<br/><br/>- В рамках сервиса **один раз при создании задаётся гарантированная доля CPU/RAM vDC**. Изменить её невозможно — требуется пересоздание vDC. <br/>- Удаление Edge недоступно при наличии зависимых ресурсов (vApp, VM, кластер Штурвал). <br/>- При использовании ALB необходимо корректно указывать segroup, чтобы обеспечить корректное выделение пулов AVI. <br/>- При выборе режима `groupvdc` следует учитывать распределение нагрузки между несколькими vDC. <br/>- SNAT для всех ресурсов внутри Edge можно активировать через операцию modify (параметры выделения VIP и ipSpace).<br/><br/>'
lifecycle:
suspend_on_destroy_default: false
adopt_existing_on_create_default: false
outputs:
params:
- code: state_params
type: map
- code: state_out
type: map
- code: state_params_flat
type: map
- code: state_out_flat
type: map
- code: vault_secrets
type: map
sensitive: true
- code: vault_url
type: string
- code: vault_user_path
type: string
- code: vault_fields
type: list
operations:
- name: create
id: 10
kind: instance
action: create
params:
- id: 8
code: vdcUid
data_type: string
required: false
ref_svc_id: 21
descr: UUID Услуги `Виртуальный датацентр (vDC)`
man: 'Необходимо, если выбран тип подключаемой VDC: `vdc`'
sort: 10
- id: 340
code: needEnableAVI
data_type: boolean
required: true
default: "false"
value_list:
- "false"
- "true"
descr: Включение Load Balancer
man: Параметры `Наименование segroup`, `Кол-во VS на AVI` необходимо также указать
sort: 30
is_modifiable: true
- id: 341
code: virtualServicesCount
data_type: integer &gt; 0
required: false
default: "1"
maxvalue: 4
minvalue: 1
descr: Кол-во виртуальных сервисов, которые **резервируются** на AVI
man: Во избежании коллапса пока выделяется до 4<br/>Необходимо указывать, если включён параметр `Включить ALB`
sort: 50
is_modifiable: true
- id: 621
code: vdcType
data_type: string
required: true
default: vdc
value_list:
- vdc
- vdcGroup
descr: Тип родительской услуги
man: При выборе vdc обеспечивает сетевую доступность только в рамках этого vdc<br/>При выборе groupvdc обеспечивает сетевую доступность между всеми vdc, которые включены в groupvdc
sort: 0
- id: 622
code: vdcGroupUid
data_type: string
required: false
ref_svc_id: 29
descr: UUID Услуги `Группа виртуальных датацентров (groupvDC)`
man: 'Необходимо, если выбран тип подключаемой VDC: `groupvdc`'
sort: 20
- id: 825
code: qosProfile
required: false
descr: Параметр пока не работает<br/>Должен возвращать поле из ресурсной платформы типа vc из .vcd.hardware.gatewayQoSProfiles<br/><br/>Как по идее должен работать.<br/>Нужно заполнить или CFS vdcUid, или vdcGroupUid<br/>Идеально конечно проверять `if (params.vdcType == &quot;vdc&quot; && params.vdcUid != &quot;&quot;)` или `if (params.vdcType == &quot;vdcGroup&quot; && params.vdcGroupUid != &quot;&quot;)`<br/><br/>Далее надо пойти по стейту vdc -&gt; org -&gt; resPlatform, взять gatewayQoSProfiles<br/>Или пойти по стейту vdcgroup -&gt; vdc -&gt; org -&gt; resPlatform, взять gatewayQoSProfiles<br/><br/>Регулярку могу написать (Виталя)
man: if (vdcUid != '&quot;) {<br/> наборфункций1<br/>} <br/><br/>elif (vdcGroupUid != &quot;&quot;) {<br/> наборфункций2<br/>}<br/><br/>else {<br/> return &quot;Необходимо выбрать vdc или vdcgroup&quot;<br/>}
sort: 60
depends_on: vdcUid,vdcGroupUid
is_modifiable: true
- id: 1110
code: routedNetConfiguration
data_type: map-fixed
required: true
sort: 70
is_modifiable: true
sub_params:
- id: 649
code: ipAddrPool
data_type: string
required: true
default: 10.10.102.0/24
regex: ^((25[0-4]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}0/24$
man: '**Параметр не изменяемый**<br/>Адрессный пул, которая будет присвоена routed-сети. Описывается как (10.10.10.0/24). Маска 24 обязательна. (.1) - шлюз. (.2-.254) - Под адресацию для ВМ'
is_modifiable: false
- id: 650
code: mainDns
data_type: string
required: true
default: 81.22.46.22
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- id: 651
code: secondDns
data_type: string
required: true
default: 185.247.187.77
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- name: delete
id: 25
kind: instance
action: delete
man: Невозможно удалить если routed-сеть в Edge связана с:<br/>- vApp<br/>- VM<br/>- Kubernetes-кластер Штурвал<br/><br/>Запустить операцию возможно, но будет ошибка
params: []
- name: modify
id: 111
kind: instance
action: modify
man: Для создания SNAT Правила убедитесь, что в организации есть свободные IP<br/>Редактировать кол-во свободных IP можно в услуге `Организация в Cloud Director` -&gt; `modify`
params:
- id: 368
code: needEnableAVI
data_type: boolean
required: false
value_list:
- "false"
- "true"
descr: Включение Load Balancer
man: Параметры `Наименование segroup`, `Кол-во VS на AVI` необходимо также указать
sort: 10
- id: 369
code: virtualServicesCount
data_type: integer &gt; 0
required: false
maxvalue: 4
minvalue: 1
descr: Кол-во виртуальных сервисов, которые выделяются на AVI
man: Во избежании коллапса пока выделяется до 4<br/>Необходимо указывать, если включён параметр `Включить ALB`
sort: 30
- id: 372
code: ipSpaceName
data_type: string
required: false
descr: Имя ip Space для внешнего IP
man: Необходимо указывать, если включён параметр `Выделить VIP для SNAT`
sort: 50
- id: 856
code: qosProfile
data_type: string
required: false
sort: 40
- id: 1112
code: routedNetConfiguration
data_type: map-fixed
required: true
sort: 70
sub_params:
- id: 652
code: ipAddrPool
data_type: string
required: true
default: ""
regex: ^((25[0-4]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}0/24$
man: '**Параметр не изменяемый**<br/>Адрессный пул, которая будет присвоена routed-сети. Описывается как (10.10.10.0/24). Маска 24 обязательна. (.1) - шлюз. (.2-.254) - Под адресацию для ВМ'
is_modifiable: false
- id: 653
code: mainDns
data_type: string
required: true
default: ""
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- id: 654
code: secondDns
data_type: string
required: true
default: ""
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
is_modifiable: false
- name: reconcile
id: 252
kind: action
action: reconcile
params: []
+20
View File
@@ -0,0 +1,20 @@
resource "nubes_vc_nsxt" "edge" {
resource_name = var.nsxt_resource_name
# Тип родительской услуги: "vdc" (нужен vdc_uid) или "vdcGroup" (нужен vdc_group_uid)
vdc_type = var.nsxt_vdc_type
# refSvc-поле: принимает UUID или имя. Здесь берём UID созданного VDC,
# чтобы Edge гарантированно создавался после vDC.
vdc_uid = nubes_vc_vdc.vdc.id
need_enable_avi = var.nsxt_need_enable_avi
virtual_services_count = var.nsxt_virtual_services_count
# routed-сеть, которую разворачивает Edge (SingleNestedAttribute -> объект)
routed_net_configuration = {
ip_addr_pool = var.nsxt_ip_addr_pool
main_dns = var.nsxt_main_dns
second_dns = var.nsxt_second_dns
}
}
+583
View File
@@ -0,0 +1,583 @@
package templates
const Instance = `package resources_gen
import (
"context"
{{- if .NeedsStringsImport }}
"strings"
{{- end }}
{{- if .NeedsFmtImport }}
"fmt"
{{- end }}
"terraform-provider-nubes/internal/core"
"terraform-provider-nubes/internal/resources_core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
{{- if .NeedsInt64Default }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
{{- end }}
{{- if .NeedsStringDefault }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
{{- end }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
{{- if .NeedsBoolUseStateForUnknown }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
{{- end }}
{{- if .NeedsInt64UseStateForUnknown }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
{{- end }}
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// Code generated by TOOLS/resource-generator. DO NOT EDIT.
// Service: {{.Name}}
// Service ID: {{.ServiceID}}
var _ resource.Resource = &{{ToCamel .Name}}Resource{}
var _ resource.ResourceWithModifyPlan = &{{ToCamel .Name}}Resource{}
var _ resource.ResourceWithImportState = &{{ToCamel .Name}}Resource{}
type {{ToCamel .Name}}Resource struct {
client *core.UniversalClient
}
{{- range .SchemaParams }}
{{- if (IsNested .) }}
// {{NestedModelName $.Name .Code}} — вложенная модель для map-fixed параметра {{.Code}}.
type {{NestedModelName $.Name .Code}} struct {
{{- range .SubParams }}
{{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}" json:"{{.Code}}"` + "`" + `
{{- end }}
}
{{- end }}
{{- end }}
type {{ToCamel .Name}}Model struct {
ID types.String ` + "`" + `tfsdk:"id"` + "`" + `
ResourceName types.String ` + "`" + `tfsdk:"resource_name"` + "`" + `
OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + `
LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + `
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// git_revision: при изменении вызывает redeploy вместо modify.
// Опциональное поле — если не задано, modify работает как обычно.
GitRevision types.String ` + "`" + `tfsdk:"git_revision"` + "`" + `
{{- end }}
{{- range .SchemaParams }}
{{- if (IsNested .) }}
{{ToCamel .Code}} {{NestedTfType . $.Name}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
{{- else }}
{{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
{{- end }}
{{- end }}
{{- if .SupportsSuspendDestroy }}
SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + `
{{- end }}
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
{{- range .OutputParams }}
{{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
{{- end }}
}
func New{{ToCamel .Name}}Resource() resource.Resource {
return &{{ToCamel .Name}}Resource{}
}
func (r *{{ToCamel .Name}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_{{.Name}}"
}
func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
attrs := map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"resource_name": schema.StringAttribute{Required: true},
"operation_timeout": schema.StringAttribute{Optional: true},
"log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."},
{{- range .SchemaParams }}
{{- if (IsNested .) }}
"{{ToSnake .Code}}": {{NestedSchemaBlock .}}
{{- range .SubParams }}
"{{ToSnake .Code}}": schema.{{SubSchemaType .}}Attribute{
{{- if .Default }}Optional: true, Computed: true, Default: {{SubDefaultExpr .}},{{else if .Required}}Required: true,{{else}}Optional: true,{{end}}
{{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}}
},
{{- end }}
{{NestedSchemaEnd .}}
{{- else }}
"{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{
{{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}}
{{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (ShouldBeOptionalComputed .) }}Computed: true,{{- end }}
{{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}}
{{- if .Sensitive }}Sensitive: true,{{end}}
{{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{- else if ShouldUseStateForUnknown . }}PlanModifiers: []planmodifier.{{if eq (ParamType .) "types.Bool"}}Bool{boolplanmodifier.UseStateForUnknown()}{{else if eq (ParamType .) "types.Int64"}}Int64{int64planmodifier.UseStateForUnknown()}{{else}}String{stringplanmodifier.UseStateForUnknown()}{{end}},{{- end }}
},
{{- end }}
{{- end }}
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// При изменении git_revision вызывается redeploy вместо modify.
// Если не задано — modify работает без изменений.
"git_revision": schema.StringAttribute{Optional: true, MarkdownDescription: "Git revision (commit hash/tag). Changing this triggers redeploy instead of modify."},
{{- end }}
{{- if .SupportsSuspendDestroy }}
"suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})},
{{- end }}
"adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})},
{{- range .OutputParams }}
{{- if or (OutputIsMap .) (OutputIsList .) }}
"{{ToSnake .Code}}": schema.{{if OutputIsMap .}}Map{{else}}List{{end}}Attribute{Computed: true, ElementType: types.StringType{{if OutputSensitive .}}, Sensitive: true{{end}}},
{{- else }}
"{{ToSnake .Code}}": schema.StringAttribute{Computed: true{{if OutputSensitive .}}, Sensitive: true{{end}}},
{{- end }}
{{- end }}
}
resp.Schema = schema.Schema{Attributes: attrs}
}
func (r *{{ToCamel .Name}}Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
if r.client == nil {
return
}
var config *{{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
if config == nil {
return
}
var state *{{ToCamel .Name}}Model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// Destroy-план (plan == null): create-time проверку «уже существует / adopt»
// запускать нельзя — удаление не валидируется через существование инстанса.
if req.Plan.Raw.IsNull() {
return
}
if state != nil && !state.ID.IsNull() && !state.ID.IsUnknown() {
var plan {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
{{- if .HasRefSvcParams }}
// FIX(uuid-case): resolve ref_svc params НЕ делаем в ModifyPlan.
// Terraform правило: plan ОБЯЗАН равняться config для user-provided атрибутов.
// Resolve (displayName → UUID или uppercase → lowercase) нужен только для
// API-вызова в Create/Update. Делать его здесь = менять plan = ошибка
// "Provider produced invalid plan: planned value does not match config value".
{{- end }}
if !plan.ResourceName.IsNull() && !plan.ResourceName.IsUnknown() && !state.ResourceName.IsNull() && !state.ResourceName.IsUnknown() {
if plan.ResourceName.ValueString() != state.ResourceName.ValueString() {
resp.Diagnostics.AddError("Нельзя изменить resource_name", "Параметр resource_name задается при создании и не может быть изменен. Создайте новый ресурс с другим именем.")
return
}
}
{{- range .CreateOnlyParams }}
{{- if not (IsNested .) }}
if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() {
{{- if eq (ParamType .) "types.Bool" }}
if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() {
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
return
}
{{- else if eq (ParamType .) "types.Int64" }}
if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() {
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
return
}
{{- else }}
// FIX(uuid-case): сравниваем без учёта регистра — API возвращает UUID
// в lowercase, пользователь мог написать upper/mixed. Это одно и то же
// значение, менять его нельзя только если оно реально другое.
if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) {
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
return
}
{{- end }}
}
{{- end }}
{{- end }}
return
}
{{- range .SchemaParams }}
{{- if and .Required (eq (ParamDefaultExpr .) "") (not (IsNested .)) }}
if config.{{ToCamel .Code}}.IsNull() {
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
return
}
if config.{{ToCamel .Code}}.IsUnknown() {
return
}
{{- end }}
{{- end }}
// ⛔ Create-time проверка существования/усыновления здесь СОЗНАТЕЛЬНО НЕ вызывается.
//
// Причина: при tainted-ресурсе Terraform планирует ЗАМЕНУ (destroy+create), и
// create-узел замены приходит в ModifyPlan с prior state = null — ровно как у
// нового ресурса. Отличить «замену» от «создания» на этом уровне невозможно,
// поэтому проверка «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ» ложно срабатывала на
// ещё не удалённый инстанс и блокировала plan/destroy.
//
// Проверка осталась в Create (CreateExistingResourceDiagnosticsWithDomainAndServices):
// на apply она выполняется ПОСЛЕ удаления старого инстанса, поэтому конфликта уже нет.
}
func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
{{- range .SchemaParams }}
{{- if and .Required (eq (ParamDefaultExpr .) "") (not (IsNested .)) }}
if data.{{ToCamel .Code}}.IsNull() || data.{{ToCamel .Code}}.IsUnknown() {
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
return
}
{{- end }}
{{- end }}
{{- if .HasRefSvcParams }}
{{- range .CreateParams }}
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
// refSvc-поле резолвим только для API-запроса.
// config не перезаписываем: пользовательский display name или UUID должен
// пройти в state ровно в том виде, в котором его передал Terraform.
resolved{{ToCamel .Code}} := data.{{ToCamel .Code}}.ValueString()
if !data.{{ToCamel .Code}}.IsNull() && !data.{{ToCamel .Code}}.IsUnknown() {
var err error
resolved{{ToCamel .Code}}, err = r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, data.{{ToCamel .Code}}.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
}
{{- end }}
{{- end }}
{{- end }}
resourceName := data.ResourceName.ValueString()
desiredDomain := ""
{{- if .HasDomainParam }}
if !data.Domain.IsNull() && !data.Domain.IsUnknown() {
desiredDomain = data.Domain.ValueString()
}
{{- end }}
domainServiceIDs := []int{ {{- range .DomainServiceIDs }}{{.}}, {{- end }} }
resp.Diagnostics.Append(resources_core.CreateExistingResourceDiagnosticsWithDomainAndServices(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), desiredDomain, domainServiceIDs, {{.SupportsSuspendDestroy}})...)
// ⛔ Проверяем HasError ДО create — при hard-error (running без adopt, suspend без adopt,
// not created, конфликт) сайд-эффект create не должен выполняться.
if resp.Diagnostics.HasError() {
return
}
params := map[int]string{
{{- range .CreateParams }}
{{- if not (IsNested .) }}
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
{{.ID}}: resolved{{ToCamel .Code}},
{{- else }}
{{.ID}}: {{ParamFormat . (printf "data.%s" (ToCamel .Code))}},
{{- end }}
{{- end }}
{{- end }}
}
{{- range .CreateParams }}
{{- if (IsNested .) }}
if data.{{ToCamel .Code}} != nil {
params[{{.ID}}] = {{NestedJSONExpr . "data"}}
}
{{- end }}
{{- end }}
operationTimeout := ""
if !data.OperationTimeout.IsNull() && !data.OperationTimeout.IsUnknown() {
operationTimeout = data.OperationTimeout.ValueString()
}
if !data.LogLevel.IsNull() && !data.LogLevel.IsUnknown() {
ctx = core.CtxWithLogLevel(ctx, data.LogLevel.ValueString())
}
id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
data.ID = types.StringValue(id)
state, diags := resources_core.RefreshResourceState(ctx, r.client, id, {{.ServiceID}}, data, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *{{ToCamel .Name}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if state.ID.IsNull() || state.ID.IsUnknown() {
return
}
if r.client != nil {
remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.State.RemoveResource(ctx)
return
}
}
newState, diags := resources_core.RefreshResourceState(ctx, r.client, state.ID.ValueString(), {{.ServiceID}}, state, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}
func (r *{{ToCamel .Name}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan {{ToCamel .Name}}Model
var state {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
instanceID := state.ID
if instanceID.IsNull() || instanceID.IsUnknown() {
instanceID = plan.ID
}
if instanceID.IsNull() || instanceID.IsUnknown() {
resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра для modify")
return
}
hasServiceParamChanges := false
{{- range .ModifyParams }}
{{- if not (IsNested .) }}
if !hasServiceParamChanges {
if plan.{{ToCamel .Code}}.IsNull() != state.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() != state.{{ToCamel .Code}}.IsUnknown() {
hasServiceParamChanges = true
} else if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() {
{{- if eq (ParamType .) "types.Bool" }}
if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() {
hasServiceParamChanges = true
}
{{- else if eq (ParamType .) "types.Int64" }}
if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() {
hasServiceParamChanges = true
}
{{- else }}
if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) {
hasServiceParamChanges = true
}
{{- end }}
}
}
{{- end }}
{{- end }}
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// Проверяем изменился ли git_revision.
// Если да — вызываем redeploy вместо modify.
// git_revision = "" (не задано) → обычный modify.
redeployRequested := false
if !plan.GitRevision.IsNull() && !plan.GitRevision.IsUnknown() {
if state.GitRevision.IsNull() || state.GitRevision.IsUnknown() || plan.GitRevision.ValueString() != state.GitRevision.ValueString() {
redeployRequested = true
}
}
{{- end }}
if !hasServiceParamChanges{{if .HasRedeploy}} && !redeployRequested{{end}} {
// Сервисные параметры не изменились → modify НЕ вызываем.
//
// НО read-back поля (state_params*, state_out*, vault_*) обязаны быть
// перечитаны из инстанса. Раньше здесь слепо копировались значения из
// старого state, из-за чего tfstate хранил устаревшее значение:
// пример — state_params["needEnableAVI"]="false", когда на платформе уже
// true (модификатор включил ALB). Это давало ложный дрейф
// «Objects have changed outside of Terraform» на каждом plan.
plan.ID = instanceID
refreshed, refreshDiags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(refreshDiags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...)
return
}
operationTimeout := ""
if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() {
operationTimeout = plan.OperationTimeout.ValueString()
}
if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() {
ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString())
}
{{- if .HasRedeploy }}
// --- Redeploy support (ARCHITECTURE.md) ---
// 1. Modify — только если изменились параметры сервиса
if hasServiceParamChanges {
{{- end }}
params := map[int]string{
{{- range .ModifyParams }}
{{- if not (IsNested .) }}
{{.ID}}: {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}},
{{- end }}
{{- end }}
}
{{- range .ModifyParams }}
{{- if (IsNested .) }}
if plan.{{ToCamel .Code}} != nil {
params[{{.ID}}] = {{NestedJSONExpr . "plan"}}
}
{{- end }}
{{- end }}
if err := resources_core.UpdateResourceWithTimeout(ctx, r.client, instanceID.ValueString(), params, operationTimeout); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
{{- if .HasRedeploy }}
}
// 2. Redeploy — только если изменился git_revision
if redeployRequested {
redeployParams := map[int]string{}
{{- range .RedeployParams }}
{{- if (IsNested .) }}
if plan.{{ToCamel .Code}} != nil {
redeployParams[{{.ID}}] = {{NestedJSONExpr . "plan"}}
}
{{- else }}
redeployParams[{{.ID}}] = {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}
{{- end }}
{{- end }}
if err := r.client.RunRedeployOperation(ctx, instanceID.ValueString(), operationTimeout, redeployParams); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
}
{{- end }}
plan.ID = instanceID
state, diags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{
{{- range .OutputParams }}
{Code: "{{.Code}}"},
{{- end }}
}, []resources_core.InputField{
{{- range .SchemaParams }}
{{- if eq .RefSvcId 0 }}
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
{{- end }}
{{- end }}
})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state {{ToCamel .Name}}Model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if state.ID.IsNull() || state.ID.IsUnknown() {
return
}
{{- if .SupportsSuspendDestroy }}
deleteMode := "state_only"
if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() {
deleteMode = "suspend"
}
{{- else }}
deleteMode := "delete"
{{- end }}
operationTimeout := ""
if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() {
operationTimeout = state.OperationTimeout.ValueString()
}
if !state.LogLevel.IsNull() && !state.LogLevel.IsUnknown() {
ctx = core.CtxWithLogLevel(ctx, state.LogLevel.ValueString())
}
if err := resources_core.DeleteResourceWithTimeout(ctx, r.client, state.ID.ValueString(), deleteMode, operationTimeout); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
}
func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
func (r *{{ToCamel .Name}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Error", "Invalid client type")
return
}
r.client = client
}
`
+473
View File
@@ -0,0 +1,473 @@
// Package loader — загрузка YAML-спеков и построение GenResource/GenSubresource/GenAction.
//
// LoadSpecs — главная функция. Для каждого YAML:
// 1. Парсит операции (create/modify/delete/suspend/resume/...)
// 2. Классифицирует их на instance/subresource/action
// 3. Сливает параметры, вычисляет CreateOnly и ForceNew
// 4. Строит GenResource/GenSubresource/GenAction
// 5. Сортирует результат по имени сервиса
//
// ValidateSpec — fail-fast валидация (паника при неизвестном Kind).
package loader
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
"resource-generator/internal/params"
"resource-generator/internal/types"
"tf-tools/lib"
)
// LoadSpecs загружает все YAML-спеки из директории и строит модели всех ресурсов.
func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, []types.GenModifier, error) {
var services []types.GenResource
var subs []types.GenSubresource
var actions []types.GenAction
var modifiers []types.GenModifier
domainServiceIDsSet := map[int]struct{}{}
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
var spec types.ServiceSpec
if err := yaml.Unmarshal(b, &spec); err != nil {
return err
}
// P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях.
if err := ValidateSpec(path, &spec); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
createParams := []types.Param{}
modifyParams := []types.Param{}
supportsSuspendDestroy := false
for _, op := range spec.Operations {
if op.Kind == "modifier" {
name := strings.TrimSpace(op.Modifier)
if name == "" {
name = strings.TrimSpace(op.Action)
}
modifier := types.GenModifier{
ServiceName: spec.Name,
ServiceID: spec.ServiceID,
ModifierName: name,
OperationName: op.Action,
Params: ConvertParams(op.Params),
DeleteStrategy: normalizeDeleteStrategy(op.DeleteStrategy),
Idempotency: normalizeIdempotency(op.Idempotency),
}
modifier.DeleteParams = convertDeleteParams(op.DeleteParams)
modifier.SchemaParams = modifier.Params
for idx := range modifier.SchemaParams {
if modifier.SchemaParams[idx].HasSubParams {
modifier.SchemaParams[idx].IsJson = true
}
}
params.Analyze(&modifier.UsesBool, &modifier.UsesInt64, &modifier.UsesString, &modifier.HasDefaults, &modifier.NeedsBoolDefault, &modifier.NeedsInt64Default, &modifier.NeedsStringDefault, modifier.SchemaParams)
modifier.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(modifier.SchemaParams)
modifiers = append(modifiers, modifier)
continue
}
if op.Kind != "instance" {
continue
}
switch op.Action {
case "create":
createParams = ConvertParams(op.Params)
case "modify":
modifyParams = ConvertParams(op.Params)
case "suspend":
supportsSuspendDestroy = true
}
}
schemaParams := params.Merge(createParams, modifyParams)
createParams = params.AlignParamTypes(createParams, schemaParams)
modifyParams = params.AlignParamTypes(modifyParams, schemaParams)
createOnly := params.ComputeCreateOnly(createParams, modifyParams)
schemaParams = params.MarkCreateOnly(schemaParams, createOnly)
hasDomainParam := false
for _, param := range schemaParams {
if strings.EqualFold(strings.TrimSpace(param.Code), "domain") {
hasDomainParam = true
domainServiceIDsSet[spec.ServiceID] = struct{}{}
break
}
}
adoptExistingOnCreate := false
suspendOnDestroy := true
if spec.Lifecycle.SuspendOnDestroyDefault != nil {
suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault
}
gr := types.GenResource{
Name: spec.Name,
ServiceID: spec.ServiceID,
CreateParams: createParams,
ModifyParams: modifyParams,
SchemaParams: schemaParams,
CreateOnlyParams: params.FilterCreateOnly(schemaParams),
CreateOnlyRequiredParams: params.FilterCreateOnlyRequired(schemaParams),
OutputParams: NormalizeOutputParams(spec.Outputs.Params),
HasRefSvcParams: HasRefSvcParams(schemaParams),
SupportsSuspendDestroy: supportsSuspendDestroy,
SuspendOnDestroy: suspendOnDestroy,
AdoptExistingOnCreate: adoptExistingOnCreate,
HasDomainParam: hasDomainParam,
}
params.Analyze(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams)
gr.NeedsBoolUseStateForUnknown, gr.NeedsInt64UseStateForUnknown = analyzeUseStateForUnknown(gr.SchemaParams)
// Анализируем nested sub-params для default-импортов
if params.AnalyzeNestedDefaults(&gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) {
gr.NeedsFmtImport = true
}
gr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(gr.SchemaParams)
// Строковый import нужен только для обычных строковых сравнений и
// обработок в шаблоне. Старый restore-хук для refSvc больше не
// генерируется, поэтому отдельный флаг под него не нужен.
gr.NeedsStringsImport = params.AnalyzeNeedsStrings(gr.ModifyParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyRequiredParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyParams)
gr.NeedsFmtImport = params.HasNestedParams(gr.SchemaParams)
// --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) ---
for _, op := range spec.Operations {
if op.Kind != "action" {
continue
}
switch op.Action {
case "redeploy":
gr.HasRedeploy = true
gr.RedeployParams = ConvertParams(op.Params)
case "restart", "recovery", "reconcile":
// Исключены
default:
act := types.GenAction{
ServiceName: spec.Name,
ServiceID: spec.ServiceID,
ActionName: op.Action,
OperationName: op.Name,
Params: ConvertParams(op.Params),
}
act.SchemaParams = act.Params
params.Analyze(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams)
act.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(act.SchemaParams)
act.NeedsStringsImport = params.AnalyzeNeedsStrings(act.SchemaParams)
actions = append(actions, act)
}
}
services = append(services, gr)
srByName := map[string]*types.GenSubresource{}
for _, op := range spec.Operations {
if op.Kind != "subresource" {
continue
}
name := strings.TrimSpace(op.Subresource)
if name == "" {
continue
}
sr := srByName[name]
if sr == nil {
sr = &types.GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name}
srByName[name] = sr
}
switch op.Action {
case "create":
sr.CreateOpName = op.Name
sr.CreateParams = ConvertParams(op.Params)
case "modify":
sr.ModifyOpName = op.Name
sr.ModifyParams = ConvertParams(op.Params)
case "delete":
sr.DeleteOpName = op.Name
sr.DeleteParams = ConvertParams(op.Params)
}
}
for _, sr := range srByName {
sr.SchemaParams = params.Merge(sr.CreateParams, sr.ModifyParams, sr.DeleteParams)
sr.CreateParams = params.AlignParamTypes(sr.CreateParams, sr.SchemaParams)
sr.ModifyParams = params.AlignParamTypes(sr.ModifyParams, sr.SchemaParams)
sr.DeleteParams = params.AlignParamTypes(sr.DeleteParams, sr.SchemaParams)
createOnly := params.ComputeCreateOnly(sr.CreateParams, sr.ModifyParams)
sr.SchemaParams = params.MarkCreateOnly(sr.SchemaParams, createOnly)
forceNewCodes := params.BuildSubresourceForceNewCodes(sr, createOnly)
sr.SchemaParams = params.MarkForceNew(sr.SchemaParams, forceNewCodes)
sr.IdentityParams = params.FilterForceNew(sr.SchemaParams)
if len(sr.IdentityParams) == 0 {
sr.IdentityParams = sr.SchemaParams
}
sr.HasRefSvcParams = HasRefSvcParams(sr.SchemaParams)
params.Analyze(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams)
params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams)
sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams)
sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams)
subs = append(subs, *sr)
}
return nil
})
if walkErr != nil {
return nil, nil, nil, nil, walkErr
}
domainServiceIDs := make([]int, 0, len(domainServiceIDsSet))
for serviceID := range domainServiceIDsSet {
domainServiceIDs = append(domainServiceIDs, serviceID)
}
sort.Ints(domainServiceIDs)
for idx := range services {
if len(domainServiceIDs) == 0 {
services[idx].DomainServiceIDs = nil
continue
}
services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...)
}
sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
sort.Slice(subs, func(i, j int) bool {
if subs[i].ServiceName == subs[j].ServiceName {
return subs[i].SubName < subs[j].SubName
}
return subs[i].ServiceName < subs[j].ServiceName
})
sort.Slice(actions, func(i, j int) bool {
if actions[i].ServiceName == actions[j].ServiceName {
return actions[i].ActionName < actions[j].ActionName
}
return actions[i].ServiceName < actions[j].ServiceName
})
sort.Slice(modifiers, func(i, j int) bool {
if modifiers[i].ServiceName == modifiers[j].ServiceName {
return modifiers[i].ModifierName < modifiers[j].ModifierName
}
return modifiers[i].ServiceName < modifiers[j].ServiceName
})
return services, subs, actions, modifiers, nil
}
// ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов.
func ConvertParams(params []types.ParamSpec) []types.Param {
out := make([]types.Param, 0, len(params))
for _, p := range params {
typeName := NormalizeParamType(p.DataType)
defVal := NormalizeDefault(p.Default)
ref := 0
if p.RefSvcID != nil {
ref = *p.RefSvcID
}
isNested := len(p.SubParams) > 0
// Рекурсивно конвертируем SubParams для map-fixed/array-map-fixed.
subParams := make([]types.Param, 0, len(p.SubParams))
for _, sp := range p.SubParams {
sub := ConvertParams([]types.ParamSpec{sp})
if len(sub) > 0 {
subParams = append(subParams, sub[0])
}
}
out = append(out, types.Param{
ID: p.ID,
Code: p.Code,
Type: typeName,
Required: p.Required,
Default: defVal,
RefSvcId: ref,
Descr: strings.TrimSpace(p.Descr),
Man: strings.TrimSpace(p.Man),
Sensitive: p.IsSensitive,
IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"),
HasSubParams: isNested,
SubParams: subParams,
IsModifiable: p.IsModifiable,
})
}
return out
}
// NormalizeParamType нормализует строку data_type → bool | int64 | string | map-fixed | array-map-fixed.
func NormalizeParamType(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
if v == "map-fixed" || v == "array-map-fixed" {
return v
}
if strings.Contains(v, "bool") {
return "bool"
}
if strings.Contains(v, "int") || strings.Contains(v, "number") {
return "int64"
}
return "string"
}
// NormalizeDefault нормализует значение по умолчанию в строку.
func NormalizeDefault(value interface{}) string {
if value == nil {
return ""
}
switch t := value.(type) {
case string:
return strings.TrimSpace(t)
default:
return fmt.Sprintf("%v", t)
}
}
// NormalizeOutputParams нормализует выходные параметры.
func NormalizeOutputParams(params []types.OutputParam) []types.OutputParam {
if len(params) == 0 {
return []types.OutputParam{}
}
return params
}
// HasRefSvcParams проверяет наличие ref_svc-параметров.
func HasRefSvcParams(params []types.Param) bool {
for _, p := range params {
if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") {
return true
}
}
return false
}
// KnownKinds — допустимые значения Kind операций.
var KnownKinds = map[string]bool{
"instance": true,
"subresource": true,
"action": true,
"modifier": true,
}
// ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds.
// P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования.
func ValidateSpec(path string, spec *types.ServiceSpec) error {
if spec.Name == "" {
return fmt.Errorf("missing required field: name")
}
if spec.ServiceID <= 0 {
return fmt.Errorf("missing required field: service_id")
}
for i, op := range spec.Operations {
if op.Kind == "" {
return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name)
}
if !KnownKinds[op.Kind] {
return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action, modifier)", i, op.Name, op.Kind)
}
if op.Action == "" {
return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind)
}
if op.Kind == "subresource" && op.Subresource == "" {
return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name)
}
if op.Kind == "modifier" && strings.TrimSpace(op.Action) == "" {
return fmt.Errorf("operation[%d] %q (kind=modifier): missing required field: action", i, op.Name)
}
if op.Kind == "modifier" {
if err := validateModifierOperation(i, &op); err != nil {
return err
}
}
}
return nil
}
// normalizeDeleteStrategy возвращает каноническое значение delete_strategy.
// Пусто → noop_warn; неизвестное — как есть (валидация в ValidateSpec отклонит раньше).
func normalizeDeleteStrategy(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw))
if v == "" {
return "noop_warn"
}
return v
}
// normalizeIdempotency возвращает каноническое значение idempotency.
// Пусто → none.
func normalizeIdempotency(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw))
if v == "" {
return "none"
}
return v
}
// convertDeleteParams переносит lib.DeleteParam → types.DeleteParam (wire-строки).
func convertDeleteParams(raw []lib.DeleteParam) []types.DeleteParam {
if len(raw) == 0 {
return nil
}
out := make([]types.DeleteParam, 0, len(raw))
for _, p := range raw {
out = append(out, types.DeleteParam{Code: strings.TrimSpace(p.Code), Value: p.Value})
}
return out
}
// validateModifierOperation — fail-fast для полей modifier-операции.
func validateModifierOperation(i int, op *lib.OperationSpec) error {
ds := normalizeDeleteStrategy(op.DeleteStrategy)
switch ds {
case "noop_warn", "inverse", "error":
default:
return fmt.Errorf("operation[%d] %q (kind=modifier): unknown delete_strategy %q (valid: noop_warn, inverse, error)", i, op.Name, op.DeleteStrategy)
}
idem := normalizeIdempotency(op.Idempotency)
switch idem {
case "none", "check_before_run":
default:
return fmt.Errorf("operation[%d] %q (kind=modifier): unknown idempotency %q (valid: none, check_before_run)", i, op.Name, op.Idempotency)
}
if ds == "inverse" && len(op.DeleteParams) == 0 {
return fmt.Errorf("operation[%d] %q (kind=modifier): delete_strategy=inverse требует delete_params", i, op.Name)
}
// каждый delete_params.code обязан существовать среди params (по lower-code)
paramCodes := make(map[string]struct{}, len(op.Params))
for _, p := range op.Params {
paramCodes[strings.ToLower(strings.TrimSpace(p.Code))] = struct{}{}
}
for _, dp := range op.DeleteParams {
if _, ok := paramCodes[strings.ToLower(strings.TrimSpace(dp.Code))]; !ok {
return fmt.Errorf("operation[%d] %q (kind=modifier): delete_params.code %q отсутствует в params", i, op.Name, dp.Code)
}
}
return nil
}
// analyzeUseStateForUnknown определяет, нужны ли импорты boolplanmodifier/int64planmodifier:
// есть ли среди SchemaParams скалярный Optional+Computed (без Default) параметр
// соответствующего типа, для которого генерируется UseStateForUnknown().
func analyzeUseStateForUnknown(schemaParams []types.Param) (needsBool, needsInt64 bool) {
for _, p := range schemaParams {
if p.IsJson || p.Required || p.RefSvcId != 0 || p.Default != "" {
continue
}
switch strings.ToLower(p.Type) {
case "bool":
needsBool = true
case "int", "int64", "number":
needsInt64 = true
}
}
return needsBool, needsInt64
}
+57
View File
@@ -0,0 +1,57 @@
# =============================================================================
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
#
# Порядок строго такой:
# орга (создана вручную в ЛК)
# -> nubes_vc_vdc.vdc
# -> nubes_vc_nsxt.edge
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
#
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
# созданный vDC и Edge. Иначе modify на орге падает
# («Can't cast Complex Object Type Struct to String»).
# =============================================================================
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" {
organization = var.organization
vip_configure = jsonencode([
{
name = var.ip_space_name
count = var.ip_count
}
])
# false = при destroy отправить обратный modify с count=0 (квота обнулится)
keep_on_destroy = false
depends_on = [nubes_vc_nsxt.edge]
}
# 2. SNAT на эдже (modify: ipSpaceName)
resource "nubes_vc_nsxt_snat" "snat" {
nsxt_uid = nubes_vc_nsxt.edge.id
ip_space_name = var.ip_space_name
keep_on_destroy = false
# ipSpace должен быть уже выделен на организации
depends_on = [nubes_vc_org_ip_allocation.org_ip]
}
output "allocated_org_ip" {
description = "Выделено внешних IP на организации"
value = {
organization = var.organization
ip_space_name = var.ip_space_name
ip_count = var.ip_count
}
}
output "snat_ip_space" {
description = "ipSpace, включённый как SNAT на эдже"
value = nubes_vc_nsxt_snat.snat.ip_space_name
}
+157
View File
@@ -0,0 +1,157 @@
# =============================================================================
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
#
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
# или закомментировать ресурс.
#
# Порядок (чек-лист из инструкции на услугу в ЛК):
# 1) Организация в Cloud Director — создана вручную в ЛК
# 2) nubes_vc_vdc.vdc — есть
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
# 5) SNAT на Edge — nubes_vc_nsxt_snat
# 6) Kubernetes кластер Штурвал — этот ресурс
#
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
# =============================================================================
# --- Переменные Штурвала ---
variable "shturval_resource_name" {
type = string
default = "shturval-dev"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-00"
description = "Имя кластера внутри Штурвала"
}
variable "shturval_app_version" {
type = string
default = "2.14.0"
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
}
variable "shturval_cp_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
}
variable "shturval_cp_sizing_disk" {
type = number
default = 50
description = "Диск control plane, ГБ (минимум 50)"
}
variable "shturval_cp_count" {
type = number
default = 1
description = "Количество мастер-нод: 1, 3 или 5"
}
variable "shturval_worker_group_name" {
type = string
default = "workers-shturval-dev"
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
}
variable "shturval_worker_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
}
variable "shturval_worker_sizing_disk" {
type = number
default = 50
description = "Диск воркеров, ГБ (минимум 50)"
}
variable "shturval_worker_count" {
type = number
default = 1
description = "Количество воркер-нод (минимум 1)"
}
# --- Значения, которые собираются из переменных ---
locals {
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
# sizingDisk, count, autoscale, labelDeck.
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
# в snake_case — это ошибка генератора, платформа на них падает с
# «Cannot invoke method split() on null object» (не находит groupName → null).
shturval_worker_config = jsonencode([
{
groupName = var.shturval_worker_group_name
sizingPolicy = var.shturval_worker_sizing_policy
sizingDisk = var.shturval_worker_sizing_disk
count = var.shturval_worker_count
autoscale = false # автоскейл выключен
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
}
])
}
# --- Ресурс Штурвала ---
resource "nubes_k8s_sthutrval_cluster" "shturval" {
resource_name = var.shturval_resource_name
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
adopt_existing_on_create = true
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
# иначе провайдер сдаётся на дефолтных 600 с.
operation_timeout = "60m"
startup_configuration = {
# vDC и Edge из этого же конфига (обязательные поля)
vdc_uid = nubes_vc_vdc.vdc.id
nsxt_uid = nubes_vc_nsxt.edge.id
cluster_name = var.shturval_cluster_name
# Дополнительные возможности кластера (в ЛК — галочки при создании)
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
ex_local_csi = true
ex_vip = true
ex_update = true
ex_ingress = true
ex_named_csi = true
}
cluster_configuration = {
app_version = var.shturval_app_version
}
control_plane_configuration = {
sizing_policy = var.shturval_cp_sizing_policy
sizing_disk = var.shturval_cp_sizing_disk
count = var.shturval_cp_count
}
worker_configuration = local.shturval_worker_config
access_configuration = {
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
access_ip_list_api = jsonencode([]) # пусто = доступ всем
need_external_address_ingress = true # внешний адрес для Ingress
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
}
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
depends_on = [nubes_vc_nsxt_snat.snat]
}
+183
View File
@@ -0,0 +1,183 @@
// Package types — структуры данных для генератора Terraform-провайдера.
//
// Моделирует три вида ресурсов:
// - GenResource — основной CRUD инстанса (nubes_{service})
// - GenSubresource — подресурсы (nubes_{service}_{user}, ...)
// - GenAction — действия (почти не используется, redeploy встроен в GenResource.HasRedeploy)
//
// Правила обработки action'ов из ARCHITECTURE.md:
// - redeploy → HasRedeploy=true, поле git_revision в основном ресурсе
// - restart/recovery/reconcile → исключены (ручные, только UI)
// - всё остальное → отдельный GenAction
package types
import "tf-tools/lib"
// ─── Алиасы к lib (общий YAML-контракт) ─────────────────────────────────────
type OutputParam = lib.OutputParam
type OperationSpec = lib.OperationSpec
type ParamSpec = lib.ParamSpec
// ─── Входные структуры (из YAML) — локальное определение ────────────────────
// ServiceSpec — локальное определение (отличается вложенными типами от lib).
// Использует *bool для Lifecycle (совместимость с YAML-парсингом).
type ServiceSpec struct {
Name string `yaml:"name"` // snake_case имя (postgres, s3bucket, ...)
ServiceID int `yaml:"service_id"` // числовой ID сервиса в Nubes
Outputs struct {
Params []OutputParam `yaml:"params"`
} `yaml:"outputs"`
Lifecycle struct {
SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"`
AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"`
} `yaml:"lifecycle"`
Operations []OperationSpec `yaml:"operations"`
}
// Param — нормализованный параметр (после конвертации из ParamSpec).
type Param struct {
ID int
Code string
Type string
Required bool
Default string
RefSvcId int
Descr string
Man string
Sensitive bool
CreateOnly bool
ForceNew bool
// IsModifiable — флаг is_modifiable из API (можно менять в UI → можно менять в Terraform).
// nil означает «не указано» (как правило, изменяемо).
IsModifiable *bool
// IsJson — атрибут является JSON-строкой (data_type: json в YAML-спеке).
// При IsJson=true в схему добавляется JsonNormalize план-модификатор,
// чтобы план и API-ответ (компактный JSON) всегда совпадали.
IsJson bool
// HasSubParams — параметр имеет подполя (map-fixed или array-map-fixed).
HasSubParams bool
// SubParams — подполя для map-fixed/array-map-fixed параметров.
SubParams []Param
}
// GenResource — ресурс инстанса (nubes_{service}).
type GenResource struct {
Name string
ServiceID int
CreateParams []Param
ModifyParams []Param
SchemaParams []Param
CreateOnlyParams []Param
CreateOnlyRequiredParams []Param
OutputParams []OutputParam
HasRefSvcParams bool
SupportsSuspendDestroy bool
SuspendOnDestroy bool
AdoptExistingOnCreate bool
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsBoolUseStateForUnknown bool
NeedsInt64UseStateForUnknown bool
HasDomainParam bool
DomainServiceIDs []int
// HasRedeploy: сервис поддерживает redeploy (пересборка из git).
// Если true — в основной ресурс добавляется поле git_revision.
// При изменении git_revision вызывается redeploy вместо modify.
// Правило из ARCHITECTURE.md: только redeploy включается как inline action.
// restart, recovery, reconcile — исключены (ручные операции, только UI).
HasRedeploy bool
// RedeployParams — параметры redeploy-операции (если есть).
RedeployParams []Param
// NeedsJsonPlanMod: хотя бы один атрибут имеет data_type: json.
// Управляет добавлением импорта planmodifier в сгенерированный файл.
NeedsJsonPlanMod bool
// NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update).
NeedsStringsImport bool
// NeedsFmtImport: есть nested (map-fixed) параметры, нужен fmt.Sprintf.
NeedsFmtImport bool
}
// GenSubresource — подресурс (nubes_{service}_{sub}).
type GenSubresource struct {
ServiceName string
ServiceID int
SubName string
CreateOpName string
ModifyOpName string
DeleteOpName string
CreateParams []Param
ModifyParams []Param
DeleteParams []Param
SchemaParams []Param
IdentityParams []Param
HasRefSvcParams bool
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsBoolPlanMod bool
NeedsInt64PlanMod bool
NeedsStringPlanMod bool
NeedsJsonPlanMod bool
NeedsStringsImport bool
}
// GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource).
type GenAction struct {
ServiceName string
ServiceID int
ActionName string
OperationName string
Params []Param
SchemaParams []Param
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsJsonPlanMod bool
NeedsStringsImport bool
}
// GenModifier — отдельный ресурс для отложенной parent-level modify операции.
// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён.
type GenModifier struct {
ServiceName string
ServiceID int
ModifierName string
OperationName string
Params []Param
SchemaParams []Param
// DeleteStrategy — noop_warn | inverse | error (нормализовано из YAML, пусто → noop_warn).
DeleteStrategy string
// Idempotency — none | check_before_run (нормализовано из YAML, пусто → none).
Idempotency string
// DeleteParams — обратные значения (wire-строки), только при DeleteStrategy==inverse.
DeleteParams []DeleteParam
UsesBool bool
UsesInt64 bool
UsesString bool
HasDefaults bool
NeedsBoolDefault bool
NeedsInt64Default bool
NeedsStringDefault bool
NeedsJsonPlanMod bool
}
// DeleteParam — обратное значение параметра inverse-Delete (code → wire-строка).
type DeleteParam struct {
Code string
Value string
}
@@ -0,0 +1,151 @@
# =============================================================================
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
#
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
# или закомментировать ресурс.
#
# Порядок (чек-лист из инструкции на услугу в ЛК):
# 1) Организация в Cloud Director — создана вручную в ЛК
# 2) nubes_vc_vdc.vdc — есть
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
# 5) SNAT на Edge — nubes_vc_nsxt_snat
# 6) Kubernetes кластер Штурвал — этот ресурс
#
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
# =============================================================================
# --- Переменные Штурвала ---
variable "shturval_resource_name" {
type = string
default = "shturval-dev"
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
}
variable "shturval_cluster_name" {
type = string
default = "shturval-dev-00"
description = "Имя кластера внутри Штурвала"
}
variable "shturval_app_version" {
type = string
default = "2.14.0"
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
}
variable "shturval_cp_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
}
variable "shturval_cp_sizing_disk" {
type = number
default = 50
description = "Диск control plane, ГБ (минимум 50)"
}
variable "shturval_cp_count" {
type = number
default = 1
description = "Количество мастер-нод: 1, 3 или 5"
}
variable "shturval_worker_group_name" {
type = string
default = "workers-shturval-dev"
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
}
variable "shturval_worker_sizing_policy" {
type = string
default = "TKG 4CPU 8RAM"
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
}
variable "shturval_worker_sizing_disk" {
type = number
default = 50
description = "Диск воркеров, ГБ (минимум 50)"
}
variable "shturval_worker_count" {
type = number
default = 1
description = "Количество воркер-нод (минимум 1)"
}
# --- Значения, которые собираются из переменных ---
locals {
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
# sizingDisk, count, autoscale, labelDeck.
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
# в snake_case — это ошибка генератора, платформа на них падает с
# «Cannot invoke method split() on null object» (не находит groupName → null).
shturval_worker_config = jsonencode([
{
groupName = var.shturval_worker_group_name
sizingPolicy = var.shturval_worker_sizing_policy
sizingDisk = var.shturval_worker_sizing_disk
count = var.shturval_worker_count
autoscale = false # автоскейл выключен
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
}
])
}
# --- Ресурс Штурвала ---
resource "nubes_k8s_sthutrval_cluster" "shturval" {
resource_name = var.shturval_resource_name
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
# иначе провайдер сдаётся на дефолтных 600 с.
operation_timeout = "60m"
startup_configuration = {
# vDC и Edge из этого же конфига (обязательные поля)
vdc_uid = nubes_vc_vdc.vdc.id
nsxt_uid = nubes_vc_nsxt.edge.id
cluster_name = var.shturval_cluster_name
# Дополнительные возможности кластера (в ЛК — галочки при создании)
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
ex_local_csi = true
ex_vip = true
ex_update = true
ex_ingress = true
ex_named_csi = true
}
cluster_configuration = {
app_version = var.shturval_app_version
}
control_plane_configuration = {
sizing_policy = var.shturval_cp_sizing_policy
sizing_disk = var.shturval_cp_sizing_disk
count = var.shturval_cp_count
}
worker_configuration = local.shturval_worker_config
access_configuration = {
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
access_ip_list_api = jsonencode([]) # пусто = доступ всем
need_external_address_ingress = true # внешний адрес для Ingress
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
}
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
depends_on = [nubes_vc_nsxt_snat.snat]
}
+6
View File
@@ -0,0 +1,6 @@
provider_installation {
dev_overrides {
"tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes" = "/home/naeel/TF/tf_provider/TMP/devbin"
}
direct {}
}
+31
View File
@@ -212,3 +212,34 @@ From the unified YAML, generate:
- No manual edits to generated YAML or generated Go code.
- Any change must come from API or generator logic updates.
- The generator must enforce these rules and fail fast on drift.
## Exception Registry (service-specific DATA, never logic)
Principle: provider core and generator logic are universal for all stands and
services. The ONLY allowed deviations are DATA entries, and they MUST live in
exactly two named registries:
| Registry | File | Declares |
|---|---|---|
| `serviceSpecificModifiers` | `TOOLS/yaml-generator/main.go` | which service `modify` op becomes a modifier resource and its name (key = normalized service name) |
| `serviceSpecificDocExamples` | `TOOLS/docs-generator/internal/writers/writers.go` | per-service doc examples, gated on service name + required state/vault keys |
Rules:
- Key by stable service NAME (slug), never by raw numeric ID.
- Each entry answers WHAT / WHAT IT DOES / WHY / WHERE (see code comments).
- Adding an exception = editing one of these two registries → visible in diff.
- Never annotate API-YAML: it is machine-regenerated and edits would be lost.
Enforced by scripts (run before build/commit):
- `TOOLS/scripts/check_generated_drift.sh <stand>` — generated Go vs provider copy.
- `TOOLS/scripts/check_hardcoded_service_ids.sh` — forbids `svc.ID == N` /
`ServiceID == N` outside the registries.
Build rule: `provider/internal/resources_gen` and `provider/resources_yaml` are
ephemeral by design and never a build source. Canonical build is
`03_build_and_upload_provider.sh` (temp copy from `generated/<stand>/go`);
`build-provider.sh` refuses direct build from `provider/`. For local IDE,
`go build` and `go test`, materialize one stand first:
`TOOLS/scripts/dev-materialize.sh <stand>` (output is git-ignored).
+19
View File
@@ -24,6 +24,25 @@ cd resource-generator && go build -o ../bin/resource-generator .
Структура: `main.go` + `internal/{helpers,loader,params,templates,types,writers}`.
### Как запускать правильно
Не запускайте `TOOLS/resource-generator/bin/resource-generator` вручную и не полагайтесь на старый бинарник из `TOOLS/resource-generator/bin/`.
Используйте канонический скрипт из корня репозитория, он **всегда** пересобирает генераторы из текущих исходников перед запуском:
```bash
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev
```
Для других стендов подставляйте нужный профиль:
```bash
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/test
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/prod
```
Это устраняет случайный запуск устаревшего бинаря и гарантирует, что новые `kind` из YAML, включая `modifier`, будут обработаны текущим кодом генератора.
## docs-generator
`resources_yaml/*.yaml` → Markdown-документация в `docs/30_registry/resources/`
+1 -1
View File
@@ -4,7 +4,7 @@ TOKEN_FILE="secrets/dev.token"
# Release versions
# Version
VERSION="2.0.0"
VERSION="2.0.17"
NAMESPACE="nubes-dev"
PROVIDER_NAME="nubes"
+1 -12
View File
@@ -6,17 +6,12 @@
12 s3 # S3 Object Storage
13 s3bucket # S3 бакет
19 vcOrg # Организация в Cloud Director
# 20 vcOrgSaas # Организация [DEPRECATED] — нет в UI
21 vc_vdc # Виртуальный датацентр (vDC)
22 vc_nsxt # Сетевой шлюз периметра (Edge)
# 23 vc_vm # VM в Cloud Director (старый формат) (vc_vm) — нет в UI
# 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat)
25 vcexternalip # Публичные IP адреса
26 vapp # Виртуальный каталог ВМ (vApp)
# 27 vc_vm_v2 # VM в Cloud Director (vc_vmV2) — нет в DEV UI
28 vc_vm_v3 # Виртуальная машина
29 vcVdcGroup # Группа датацентров
# 32 vmpostgre # vc_vm_postgresql_std
50 nextcloud # Nextcloud
81 superset # Apache Superset
82 harbor # Container Registry
@@ -34,13 +29,8 @@
97 nodered # NodeRed
98 http # Простой HTTP контейнер
99 gitea # Gitea
# 100 openwhisk # Serverless Openwhisk — нет в UI
109 zonesV2 # Управление DNS
# 110 dnszone # DNS зона — нет в UI
111 dnsrecord # DNS запись
# 112 tenant # Тенант в Grafana — нет в UI
# 113 vcComplex # Быстрый старт — нет в UI
# 114 GiteaComplex # Комплексная услуга по созданию gitea — нет в UI
115 mariadb # Mariadb
116 kafka # ApacheKafka
# 117 nifi # Nifi
@@ -50,6 +40,5 @@
149 valoTenant # VALO Cloud
150 k8sSthutrvalCluster # Kubernetes кластер Штурвал
151 k8sOpenbao # Vault
# 153 nifi # Nifi (DEV)
153 nifi # Nifi (DEV)
163 llmAi # LLM
# 175 k8sGo # Go — нет в UI

Some files were not shown because too many files have changed in this diff Show More