40 Commits
Author SHA1 Message Date
Repinoid 4197a76aba 1 2026-09-30 21:01:05 +03:00
Repinoid 575f1e29a4 docs(prompt): промпт Opus — код-ревью правок (раунд 5), ответ <= 10 строк 2026-09-30 20:55:05 +03:00
Repinoid b3cce5bb70 docs(history): раунд 4 — решения Q1-Q5 и их статус 2026-09-30 20:53:41 +03:00
Repinoid 7a6f6650c6 docs(architecture): зафиксированы решения Q1/Q2/Q4
- Q1: POST не ретраится (не идемпотентен; Idempotency-Key у API нет) — решение.
- Q2: при ошибке после POST /instanceOperations и до run операция остаётся черновиком;
  отмены нет (ни в коде, ни в HAR — DELETE /instanceOperations/{uid} отсутствует).
- Q4: единый контракт жизненного цикла — keep_on_destroy + suspend_on_destroy;
  delete_strategy (YAML) = маппинг на них (noop_warn/inverse/error).
2026-09-30 20:53:31 +03:00
Repinoid 9da97663c6 fix(Q5): сохранять instanceUid при ошибке после создания + partial state
Проблема: CreateGenericInstanceUniversalV6 при ЛЮБОЙ ошибке после создания инстанса
возвращал "", а шаблон Create при ошибке не писал ID в state => облачный инстанс
осиротевал (Terraform о нём не знает, повторный apply упирается в страж дубликатов).

- core/instance_create.go: ошибки после получения instanceUid возвращают uid вместе
  с ошибкой (POST /instanceOperations, пустой opUid, разбор cfsParams, resolve,
  отправка параметров, validate, run, waitForOperationFinish, ensureInstanceCreated).
  До создания uid — по-прежнему "".
- templates/instance.go: при err != nil и id != "" -> data.ID + resp.State.Set (partial
  state), затем AddError.
- client_test.go: TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails,
  TestCreateGenericInstance_EmptyUIDWhenInstanceCreateFails.
- ARCHITECTURE.md: пункт про partial state.

Проверено: 02 (dev) + dev-materialize -> 40 файлов resources_gen содержат фикс;
go build ./... OK; go test ./internal/... -short PASS.
2026-09-30 20:53:14 +03:00
Repinoid 54f036e228 docs(history): замер Q3 — угадывание типа по имени не подтверждается
Замер по generated/dev/resources_yaml (40 файлов, только чтение):
- required-параметров: 852; с пустым data_type — 5;
- всего с пустым data_type: 12 (~1.2%); угадывание по имени даёт != '' только
  для 1 тестового (1_dummy.jsonExample) => гипотеза A4 не подтверждается,
  правка косметическая, не исправление дефекта.
2026-09-30 20:48:18 +03:00
Repinoid 5a0bb432ab docs: промпт Opus раунд 4 + журнал правок ядра/модификаторов
- NOTES/20_prompts/prompt_for_opus_remediation_round4.md — 5 коротких вопросов
  (retry POST, осиротевшая операция, zero-value по подстроке, единый словарь
  жизненного цикла, что упущено в ядре); ответ ≤ 25 строк.
- HISTORY/2026-09-30_core_and_modifiers_remediation.md — журнал: 6 коммитов,
  что отклонено (A3/A4/A5) и почему, что отложено (A2/B8/B9).
2026-09-30 20:42:30 +03:00
Repinoid 0e26e98384 docs(uuid-case): зафиксировать фактический механизм сохранения регистра
- core/refsvc.go: комментарий ссылался на несуществующий блок 'Restore user-provided
  casing' в instance.go. Факт: ref_svc исключены из read-back (InputField только при
  RefSvcId==0), UUID внутри JSON нормализуются при отправке (BuildJSON ->
  LowercaseUUIDsInText).
- docs/60_strategy/terraform_case_sensitivity_fix.md: §4 помечен как историческая
  справка (подхода originalVappUid в коде нет); актуальные §10-§11.

Проверено: go build ./... OK; go test ./internal/... -short -> PASS.
2026-09-30 20:38:37 +03:00
Repinoid 8519ba0f44 fix(modifiers): ImportState заполняет Required-атрибуты
Было: ImportState ставил только id + organization/nsxt_uid, оставляя Required-атрибуты
(vip_configure / ip_space_name) в null — Terraform не мог свести импорт с конфигом.

- nsxt_snat: importIpSpaceName(live) — live-значение, иначе канон no-needed.
- org_ip_allocation: importVipConfigure(live) — канонический JSON из live, иначе [].
- Чистые хелперы + тесты TestImportVipConfigure/TestImportIpSpaceName.

Проверено: go build ./... OK; go test ./internal/resources_core/... -short -> PASS.
Terraform import проверяется владельцем.
2026-09-30 20:36:47 +03:00
Repinoid ea75cac1a8 fix(core): idempotency pre-check сравнивает с live, а не с paramValue формы
Проблема: modifierDesiredEqualsCurrent сравнивает desired с paramValue из cfsParams
(дефолт ФОРМЫ операции), а не с состоянием инстанса. Пропуск modify на такой основе
может быть ложным (HAR/edge_.har: needEnableAVI paramValue=false при live=true).

- core/modifier_compare.go: добавлен modifierDesiredEqualsLive (источник —
  instanceLiveParams/state.params; неопределённость => не пропускаем) и хелперы
  modifierValuesEqual / modifierCodeMap; modifierDesiredEqualsCurrent переведён на них.
- core/operation_run_bycode.go: idempotent-путь использует live-pre-check; при ошибке
  чтения live modify НЕ пропускается.
- resources_core/nsxt_snat_resource.go: setSnat -> RunInstanceOperationUniversalByIdempotent
  (лишний modify на повторном apply больше не отправляется).
- modifier_compare_test.go: TestModifierDesiredEqualsLive (совпало/отличается/live недоступен).

Проверено: go build ./... OK; go test ./internal/core/... -short -> PASS.
2026-09-30 20:35:09 +03:00
Repinoid 383f8ea321 fix(core): транзиентный 401 теперь ретраится для GET
isRetryable (core/http.go): добавлен StatusUnauthorized (401) к {429,502,503,504}.
Gateway может временно отклонять валидный JWT — без этого одиночный 401 ронял
read/plan/поллинг.

- client_test.go: юнит-тест TestIsRetryable (401/429/502/503/504 = true; 400/403/404/500 = false).
- ARCHITECTURE.md: раздел API Resilience приведён к фактическому поведению
  (401 реализован; POST не ретраится намеренно).

Проверено: go build ./... OK; go test ./internal/core/... -short OK.
2026-09-30 20:33:48 +03:00
Repinoid c5a4499094 chore(tools): страж хардкодов покрыл provider/ + id сервиса в именованную константу
check_hardcoded_service_ids.sh:
- область расширена с TOOLS/ на TOOLS/ + provider/internal/ (кроме generated resources_gen/);
- второй паттерн: литеральный ref-service id в ResolveRefSvcParamValue/DisplayName(ctx, N,...);
- справка обновлена (убран удалённый serviceSpecificModifiers);
- сервис-специфичные литералы пояснены как 'именованные константы'.

org_ip_allocation_resource.go: ResolveRefSvcParamValue(ctx, 19, ...) -> svcIDVcOrg.

Проверено: bash TOOLS/scripts/check_hardcoded_service_ids.sh -> OK (exit 0).
2026-09-30 20:33:09 +03:00
Repinoid 047d53a67b docs(architecture): привести TOOLS/ARCHITECTURE.md в соответствие с кодом
- Core Principles 2-3: 'универсально/генерируется' отнесено к core/ и resources_gen/,
  а не ко всему сервис-коду.
- API Resilience: 401 НЕ ретраится (isRetryable = 429/502/503/504, только GET) —
  помечено как незакрытый разрыв со спекой.
- Exception Registry: убран удалённый реестр serviceSpecificModifiers
  (yaml-generator/main.go), описаны ручные модификаторы + несуществующий modifiers.yaml.
- Новый раздел 'Lifecycle Vocabulary': три несогласованных словаря destroy.
- Rules: 'two registries' -> один реестр + ручные ресурсы.
2026-09-30 20:32:29 +03:00
Repinoid 2c51b0392d docs(prompt): промпт для DeepSeek Pro — план правок кода/доков/архитектуры + план проверки
DeepSeek верифицирует гипотезы групп A (ядро: 401/POST-ретраи, обрыв modify, zero-value
fallback, нормализация Read) и B (модификаторы/спека), затем даёт план правок кода, правок
документации и архитектурных решений, трёхуровневый план проверки (юнит / plan-apply на
DEV_STAND/FullPipe / регрессия + стражи) и порядок работ по коммитам. Код не пишет — исполнять
будет Copilot. Журнал Opus-диалога дополнен ссылкой на этот шаг.
2026-09-30 20:20:31 +03:00
Repinoid 47e010edf3 docs(opus): дописаны пропущенные ходы журнала (Ход 10a, 12a, 14)
Записаны: запрос «твоё мнение?» по раунду 2; сообщение с путём к файлу раунда 3 и
отменённый уточняющий вопрос агента; запрос «мнение?» по раунду 3 и полное мнение агента
(в т.ч. сомнение в абсолютном выводе «401 не ретраится нигде» и в трактовке отсутствия
ретрая POST как дефекта). Статус обновлён: чат с Opus исчерпан по токенам.
2026-09-30 20:18:41 +03:00
Repinoid 96adf958bc docs(opus): раунд 3 отвечен — запись в журнал диалога
Дословно сырой лог и отчёт: 5 находок по ядру — 401 не ретраится (расхождение с
ARCHITECTURE.md:105-108), ретрай только для GET, обрыв modify после создания операции,
zero-value fallback по подстроке имени, нормализация Read только для jsonEnv/ref_svc.
Находки 1-3,5 не подтверждены замером.
2026-09-30 20:15:43 +03:00
Repinoid e46bc35b98 docs(opus): раунд 3 — фокус переведён на сам провайдер (устойчивость/корректность)
Жёсткий бюджет ради токенов: ≤10 файлов, ≤5 находок по ≤3 строки, плюс одна строка
«что проверяемо только замером». M6 снят, модификаторы — фон. Границы: ядро, resources_core,
шаблоны/хелперы генератора. В журнал добавлены крит-мнение по раунду 2 и текст раунда 3.
2026-09-30 20:11:24 +03:00
Repinoid e1e45423fa docs(opus): раунд 2 отвечен + вводная пользователя — запись в журнал диалога
Дословно: сырой лог и дельта Opus (M1 сверка номеров строк, M2 факт из check_hardcoded_service_ids.sh,
M3 снятие S4 и переклассификация S2, M4 шкала R2>R3>R6>R5, M5 разбор Read/вечного diff,
U1 modifiers.yaml не существует, U2 тройной словарь жизненного цикла, U3 варианты, M6 запрос двух файлов).
Плюс указание пользователя: модификаторы — небольшая часть, главное — сам провайдер.
2026-09-30 20:07:34 +03:00
Repinoid ea75507fe4 docs(opus): раунд 2 — замечания к отчёту Opus + запись в журнал диалога
Замечания M1–M5 (сверка номеров строк, нарушение «без догадок» в R2, натянутые S2/S4,
приоритет R5, пробел по устойчивости Read/вечный diff) и U1–U3 (modifiers.yaml не существует,
рассинхрон suspend_on_destroy/keep_on_destroy, корневая причина ручных модификаторов).
Разрешён дополнительный список файлов; право копать глубже передано Opus.
2026-09-30 19:59:44 +03:00
Repinoid dc85e7b4e0 docs(opus): полная запись диалога 2026-09-30 — промпт + отчёт Opus по архитектуре и модификаторам
Дословно, без сокращений: задание пользователя, разведка агента, содержимое промпта,
сырой лог сессии Opus и его отчёт (расхождения спека↔код S1–S5, риски R1–R6),
открытый вопрос Opus. Статус: диалог не завершён.
2026-09-30 19:57:36 +03:00
Repinoid 752244fa26 docs(prompt): промпт для Opus 4.8 — анализ архитектуры провайдера + ресурсов-модификаторов
Задача: разбор универсальной архитектуры и слоя модификаторов (nubes_vc_org_ip_allocation,
nubes_vc_nsxt_snat). Жёсткие границы доступа (запрет на HISTORY/NOTES/TMP/HAR/docs и git-историю),
исчерпывающий список из 22 файлов (1 спека + код + YAML-спеки + пример применения),
сжатый формат ответа, режим диалога с правом задать уточняющий вопрос.
2026-09-30 19:44:57 +03:00
Repinoid bed269cf27 release: перезаливка провайдера во все 3 стенда — prod 1.0.0, dev 2.0.0, test 3.0.0
Полный цикл 03 (01+02 → сборка 3 платформ → GPG → заливка S3) по каждому стенду:
- prod nubes/nubes 1.0.0;
- dev  nubes-dev/nubes 2.0.0  (VERSION в profile.env: 2.0.24 → 2.0.0);
- test nubes-test/nubes 3.0.0.

Проверено: sha256 залитого linux-бинарника == локальной сборке на всех трёх;
API /versions отдаёт 1.0.0 / 2.0.0 / 3.0.0; в S3 по 5 объектов на версию.

Последствия (приняты владельцем): версии X.0.0 перезаписаны → у пользователей
с .terraform.lock.hcl будет checksum mismatch (лечится terraform init -upgrade).
Подробности: HISTORY/2026-09-30_release_1_0_0_2_0_0_3_0_0_all_stands.md
2026-09-30 19:16:48 +03:00
Repinoid fd3ab32534 chore(dev-registry): физически удалены версии провайдера старше 2.0.21
В dev-реестре (s3://nubes-terraform-registry/.../nubes-dev/nubes/) удалены
версии 2.0.0–2.0.20 — 21 версия, 105 объектов (~800 MiB). Бакет un-versioned.

Осталось: 2.0.21, 2.0.22, 2.0.23, 2.0.24 (20 объектов).
Проверено: mc ls (20 объектов), API /versions (4), download 2.0.23/2.0.24 → HTTP 206
(ZIP), 2.0.0/2.0.20 → HTTP 404; nubes-test (3.0.0) и nubes (1.0.0) не тронуты.

Резервные копии zip не делались (прямое указание «стереть физически»);
восстановление — только пересборкой из git-истории.
Документация: HISTORY/2026-09-30_dev_registry_prune_versions.md, VERSIONS.md.
2026-09-30 10:50:45 +03:00
Repinoid 46dc548af4 docs(history): UUID-регистр — процедура релиза и карта точек нормализации
Перенесено из служебной памяти VS Code в файл репозитория:
- пошаговая процедура релиза dev (VERSION → 03 → curl-проверка → VERSIONS.md →
  dev-materialize), включая, что доки в реестр не публиковались;
- карта: нормализация нужна в двух местах (сравнение и отправка BuildJSON),
  перечень функций и правила (lower() — костыль; план целиком не нормализуем).
2026-09-30 10:32:22 +03:00
Repinoid 89bfb46b1c docs(history): этап 4 — перепроверка после перегенерации + подводные камни
Перенесено в файл репозитория (а не только в служебную память VS Code):
- результаты полной перегенерации и перепроверки всех трёх стендов;
- подводные камни: случайные default от API (детектор дрейфа по побайтовому
  сравнению не работает); случайный default вшивается в Go-код → drift 15 файлов
  сразу после перегенерации; generated/<стенд>/go|docs стареют после шага 01;
  403 без браузерного User-Agent (DDoS-Guard);
- актуальная карта пайплайна, токены, что удалено и что оставлено осознанно.
2026-09-30 10:30:41 +03:00
Repinoid 5d29010352 docs(history): этап 3 — сверка списков стендов с облачным каталогом
Зафиксирована методика: истина = GET /services?isProductionReady=true
(без фильтра API отдаёт все сервисы платформы, включая DEPRECATED).
Результаты по dev/test/prod и исправление prod (Vault).
2026-09-30 10:13:35 +03:00
Repinoid a68a36a0d2 fix(config): prod — включить Vault (151 k8sOpenbao) по сверке с облачным каталогом
Сверка TOOLS/config/prod/services_list.txt с облачным каталогом
(GET /api/v1/svc/services?isProductionReady=true → 36 сервисов):
- активных в файле было 35, лишних нет, но не хватало 151 k8sOpenbao (Vault);
- комментарий «нет в PROD UI» устарел: сервис отдаётся каталогом prod
  (isProductionReady=true, resourceRealmTypeId=3).

После правки: 36 активных = 36 в облаке, перегенерация prod дала 36 YAML,
включая 151_k8s_openbao.yaml. dev (40/40) и test (36/36) расхождений не имели.
2026-09-30 10:13:13 +03:00
Repinoid 5e1a6f06bc docs(history): этап 2 — удаление мёртвого легаси (что удалено, что оставлено)
- раздел «Этап 2» с таблицей удалённых файлов и обоснованием;
- зафиксировано, что НЕ удалено и почему (index.cfm-совместимость,
  справочная копия DOCS_PIPELINE/publish-docs.sh, исторические документы);
- результаты проверок после удаления (bash -n, smoke-прогон 01 dev).
2026-09-30 09:53:57 +03:00
Repinoid c822ae2f2a docs: актуализировать ссылки после удаления общего services_list.txt
- README.md, HOW_TO/README.md, HOW_TO/DEVOPS_BUILD_PIPELINE.md,
  HOW_TO/HOWTO_ADD_NEW_SERVICE.md, DOCS_PIPELINE/README.md:
  TOOLS/config/services_list.txt → TOOLS/config/<стенд>/services_list.txt;
- HOW_TO/HOWTO_ADD_NEW_SERVICE.md: блок «Быстрый старт» переписан с
  устаревших devops/-путей на канонические (./TOOLS/scripts/*, generated/<стенд>/);
- scripts/publish-doc-page.sh: примеры devops/profiles/<стенд> → TOOLS/config/<стенд>;
- .gitignore: убрана мёртвая строка devops/profiles/*/generated/.

Проверено: bash -n для всех TOOLS/scripts/*.sh и scripts/publish-doc-page.sh — OK.
2026-09-30 09:50:37 +03:00
Repinoid 1e796c8eb1 chore(tools): удалить мёртвые легаси-скрипты и общий services_list.txt
Удалено (100% мёртвое, ничего не вызывает):
- TOOLS/scripts/10_yaml_stability_run.sh
- TOOLS/scripts/11_yaml_stability_run_latest.sh
- TOOLS/scripts/12_generate_yamls_latest.sh
- TOOLS/scripts/13_generate_yamls_clean.sh
- TOOLS/scripts/02_generate_resources_and_docs_template_v2.sh
- TOOLS/config/services_list.txt (общий список: код его не читает, а как
  «объединение» он устарел — в нём активен id 27, который в test/prod
  закомментирован как «нет в UI», и отсутствуют 87/88/97/153 из dev)

Все ссылки в документации указывали на профильные списки либо будут
исправлены отдельным коммитом.
2026-09-30 09:50:28 +03:00
Repinoid cbd559d767 docs(tools): канонический пайплайн + история ужесточения генерации YAML
- TOOLS/README.md: раздел «Канонический пайплайн (порядок шагов)» и описание
  безопасной генерации (staging → атомарная замена, бэкапы, маркер .stand);
- TOOLS/ARCHITECTURE.md: ссылки devops/… → TOOLS/config/<stand>/…;
- HISTORY/2026-09-30_yaml_pipeline_hardening.md: полная история изменений
  (что было не так, что сделано, прогон по стендам, коммиты, проверки).
2026-09-30 09:39:26 +03:00
Repinoid ad4daab358 chore(tools): легаси-скрипты генерации отключены (fail-fast DEPRECATED)
10/11/12/13 + 02_..._template_v2 нерабочие (зовут 01 без --profile, ищут
*.token в корне репо) и не вызываются никаким рабочим скриптом (ссылки —
только в исторических HISTORY/NOTES). Теперь падают сразу с подсказкой
канонического пути вместо мнимой работы; 13 вдобавок больше не может
сделать rm -f provider/resources_yaml/*.yaml.

Проверено: bash -n OK, guard отдаёт exit 2.
2026-09-30 09:38:42 +03:00
Repinoid 12b3932817 fix(tools): безопасная генерация YAML — staging + атомарная замена, без легаси-фолбэков
01_generate_yamls.sh:
- генерация в staging-каталог; рабочий каталог не удаляется заранее;
  атомарная замена (mv) только при полном успехе, старый каталог → бэкап
  resources_yaml.bak-<UTC> (ротация KEEP_BACKUPS=5);
- убран легаси-фолбэк токена 'ls -t ROOT/*.token' (подхватывал чужой токен);
- NUBES_API_ENDPOINT обязателен (убран молчаливый PROD-дефолт);
- имя сервиса берётся из 2-го поля services_list.txt (убран лишний HTTP-запрос);
- убран безусловный rm старого YAML перед генерацией (неатомарность);
- удалён дубль SERVICES_FILE_DEFAULT; маркер .stand защищает от чужого стенда;
- синхронизированы комментарии (REQUEST_DELAY 0.5, пути, формат списка).

yaml-generator/internal/config:
- NUBES_API_ENDPOINT обязателен (убран PROD-дефолт);
- убран легаси-поиск последнего *.token в корне репо (loadToken/findLatestToken);
- убран несуществующий путь provider/devops/config/services_list.txt
  (теперь требуется явный NUBES_SERVICES_FILE);
- удалены мёртвые getenvDefault/findLatestToken.

Проверено: bash -n OK, go vet/go build OK.
2026-09-30 09:28:41 +03:00
Repinoid 190fc68f93 chore(gitignore): игнорировать каталог secrets/ 2026-09-30 09:07:47 +03:00
Repinoid b81dc88abe docs(uuid-case): отметить, что фикс выпущен в dev 2.0.24
- HISTORY/2026-09-30: раздел «Следствия» — релиз 2.0.24 (залито в реестр), доки не публиковались.
- terraform_case_sensitivity_fix.md §11: «не выпущено» -> «выпущено в 2.0.24»; уточнение по костылю lower().
2026-09-30 08:50:35 +03:00
Repinoid c1b02a1c2d release(dev): 2.0.24 — фикс регистра UUID при отправке map-fixed JSON залит в реестр
Собрано и загружено в nubes-dev/nubes/2.0.24/ (linux/windows/darwin amd64
+ SHA256SUMS + .sig), версия видна в реестре.
2026-09-30 08:50:17 +03:00
Repinoid d6d0b733ae chore(dev): поднять версию провайдера 2.0.17 -> 2.0.24
Конфиг отставал от реестра (там уже 2.0.23), из-за чего генератор доков
подставлял неверную версию. Дальше — генерация и релиз 2.0.24.
2026-09-30 08:35:59 +03:00
Repinoid 9aed2dcdd9 fix(provider): нормализация регистра UUID при отправке map-fixed JSON в API
- resources_core.BuildJSON оборачивает результат в jsonutil.LowercaseUUIDsInText:
  платформа сравнивает регистр UUID при create, а ресурсы отдают id в UPPERCASE
  (nsxtUid/vdcUid) -> без нормализации create Штурвала падал 'Edge не развёрнут
  в указанном vDC' (обнаружено на провайдере 2.0.23 из-под Windows).
- Одна точка покрывает все map-fixed-параметры (create/modify/redeploy),
  регенерация не требуется.
- Документация: HISTORY/2026-09-30, docs/60_strategy/terraform_case_sensitivity_fix.md §11,
  NOTES/30_analysis/ARCHITECTURE_NEW.md §6.5, docs/help/architecture-and-methods.md §7.

Не выпущено: версия не поднималась, релиз/регенерация не выполнялись.
2026-09-30 08:34:04 +03:00
Repinoid 09e38928d0 chore: сохранить текущие изменения стендов и заметок 2026-09-29 11:16:57 +03:00
Repinoid a52170cf1e docs(history): vpn-transit-213 — итог оптимизации: автоматизация, провал mux и DNAT, разбор ошибок 2026-09-28 16:45:04 +03:00
66 changed files with 5216 additions and 520 deletions
+1 -3
View File
@@ -36,9 +36,6 @@ provider/generated/
# Локально собранный провайдер под dev_overrides (см. TMP/terraformrc.dev)
TMP/devbin/
terraform-provider-nubes
# === Build artifacts (generated by devops scripts) ===
devops/profiles/*/generated/
*.zip
*.tar.gz
*.sha256
@@ -78,6 +75,7 @@ secrets/.s3cfg_provider
secrets/.s3cfg*
secrets/pearlharbor_registry.txt
secrets/id_ed25519.txt
secrets/
# === MkDocs ===
site/
+189
View File
@@ -0,0 +1,189 @@
# =============================================================================
# Виртуальная машина внутри vApp — услуги 26 (vApp) и 28 (ВМ)
#
# Всё, что относится к ВМ, лежит ТОЛЬКО в этом файле: переменные, их значения
# по умолчанию, оба ресурса и выводы. Чтобы выключить ВМ — удалить или
# закомментировать этот файл (по аналогии с shturval.tf).
#
# Место в цепочке:
# орга (вручную в ЛК) → vDC (21) → Edge (22) → внешние IP → SNAT
# → [ vApp (26) → ВМ (28) ] → Штурвал (150)
#
# Зависимости (из манифестов услуг, сгенерированные ресурсы):
# vApp (26) — nubes_vapp: требует vdc_uid (21) и nsxt_uid (22)
# ВМ (28) — nubes_vc_vm_v3: требует vapp_uid (26)
#
# Внешний доступ: ВМ публикуется за общим SNAT эджа (same_snat = false), для
# этого ipSpace должен быть выделен на организации и включён как SNAT
# (см. modifiers.tf). Нужен ВЫДЕЛЕННЫЙ внешний адрес — same_snat = true.
# ipSpace для ВМ берём тот же, что у SNAT (var.ip_space_name).
#
# Режим destroy: у обеих услуг операция delete требует предварительного
# suspend, поэтому по умолчанию suspend_on_destroy = true («заморозка»).
# Полное удаление vApp возможно только через 14 дней после suspend.
# =============================================================================
# --- Переменные vApp ---
variable "vapp_resource_name" {
type = string
default = "fullpipe-vapp"
description = "Имя услуги «Виртуальный каталог ВМ (vApp)» в ЛК"
}
variable "vapp_name" {
type = string
default = "fullpipe-vapp-01"
description = "Имя vApp. Маска ^[a-z0-9][a-z0-9.-]{3,61}[a-z0-9]$, уникально в организации; участвует в DNS-имени ВМ. НЕ оставлять дефолтом платформы."
}
# --- Переменные ВМ ---
variable "vm_resource_name" {
type = string
default = "fullpipe-vm-01"
description = "Имя услуги «Виртуальная машина» в ЛК"
}
variable "vm_name" {
type = string
default = "web01"
description = "Имя ВМ. Маска ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$. Определяет имя NSX-T IP Set: {vapp_name}-{vm_name}"
}
variable "vm_image" {
type = string
default = "Ubuntu_22-20G"
description = "Образ ОС. Доступные значения: RockyLinux_9-16G-cloudinit, Ubuntu_22-20G, Debian_13-20G. Не изменяется после создания"
}
variable "vm_cpu" {
type = number
default = 2
description = "vCPU (1..64), шт"
}
variable "vm_ram" {
type = number
default = 2
description = "RAM (1..256), GB"
}
variable "vm_disk" {
type = number
default = 20
description = "Дополнительный диск, GB (основной диск зависит от образа)"
}
variable "vm_user_login" {
type = string
default = "ubuntu"
description = "Учётка SSH. Не изменяется после создания"
}
variable "vm_user_public_key" {
type = string
# ВСЕ параметры ВМ живут в этом файле — включая ключ. Удалил файл — ВМ исключена
# из конфига полностью, в terraform.tfvars ничего про ВМ не остаётся.
# Здесь публичный ключ (не секрет), тот же, что в secrets/id_ed25519.pub.
# Переопределить можно в terraform.tfvars — но тогда при исключении ВМ
# надо удалить и эту строку (иного способа у Terraform нет).
default = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPR8S07Mnku1VlVR/lq6hCKPo9fNzJ+7E0DoE7bkvy4p tazet@narod.ru"
description = "Публичная часть SSH-ключа в формате OpenSSH. Не изменяется после создания. По умолчанию — ключ tazet@narod.ru"
}
variable "vm_access_port_list" {
type = list(object({
port = string
type = string
}))
default = [
{ port = "22", type = "tcp" }
]
description = "Белый список портов для доступа извне; type: tcp | udp | all"
}
variable "vm_access_ip_list" {
type = list(string)
default = ["0.0.0.0/0"]
description = "Белый список адресов, которым разрешён доступ к ВМ. Требует выделенного внешнего IP"
}
variable "vm_same_snat" {
type = bool
default = false
description = "false — публикация за общим SNAT эджа; true — за выделенным внешним IP услуги"
}
# --- vApp (услуга 26) ---
resource "nubes_vapp" "vapp" {
resource_name = var.vapp_resource_name
vapp_name = var.vapp_name
vdc_uid = nubes_vc_vdc.vdc.id # ref 21 — вычислительная инфраструктура
nsxt_uid = nubes_vc_nsxt.edge.id # ref 22 — сеть/маршрутизация
# «Заморозка»: destroy переводит vApp в suspend (delete требует suspend).
suspend_on_destroy = true
# Повторный apply усыновляет существующий vApp, а не падает с
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ».
adopt_existing_on_create = true
# vApp требует готовую сеть (ipSpace на организации + SNAT на эдже).
depends_on = [nubes_vc_nsxt_snat.snat]
}
# --- ВМ (услуга 28) ---
resource "nubes_vc_vm_v3" "vm" {
resource_name = var.vm_resource_name
vm_name = var.vm_name
vapp_uid = nubes_vapp.vapp.id # ref 26 — ВМ размещается в vApp
image_vm = var.vm_image
vm_cpu = var.vm_cpu
vm_ram = var.vm_ram
vm_disk = var.vm_disk
user_login = var.vm_user_login
user_public_key = var.vm_user_public_key
# Внешний доступ
ip_space_name = var.ip_space_name # тот же ipSpace, что у SNAT эджа
same_snat = var.vm_same_snat
access_port_list = jsonencode(var.vm_access_port_list)
access_ip_list = jsonencode(var.vm_access_ip_list)
# «Заморозка»: destroy переводит ВМ в suspend.
suspend_on_destroy = true
adopt_existing_on_create = true
# ВМ создаётся платформой долго — поднимаем таймаут ожидания.
operation_timeout = "15m"
}
# --- Выводы ---
output "vapp_id" {
description = "UID созданного vApp (услуга 26)"
value = nubes_vapp.vapp.id
}
output "vapp_name" {
description = "Имя vApp"
value = nubes_vapp.vapp.vapp_name
}
output "vm_id" {
description = "UID созданной ВМ (услуга 28)"
value = nubes_vc_vm_v3.vm.id
}
output "vm_state_flat" {
description = "Плоский state ВМ — IP-адреса, статус и т.д."
value = nubes_vc_vm_v3.vm.state_out_flat
}
+1 -1
View File
@@ -102,7 +102,7 @@ API стенда ──▶ generated/<стенд>/resources_yaml/ ──▶ gene
| `scripts/publish-docs.sh` | актуальная заливка docs в S3 (без версии) |
| `scripts/publish-doc-page.sh` | заливка одной страницы |
| `TOOLS/config/<стенд>/profile.env` | параметры стенда (endpoint, NAMESPACE, VERSION, токен, GPG) |
| `TOOLS/config/registry.env`, `services_list.txt`, `operation_timeouts.json` | конфиги реестра/генерации |
| `TOOLS/config/registry.env`, `TOOLS/config/<стенд>/{services_list.txt,operation_timeouts.json}` | конфиги реестра/генерации |
| `TOOLS/bin/` | генераторы: `resource-generator`, `docs-generator`, `yaml-generator` |
| `secrets/{dev,test,prod}.token`, `.s3cfg_registry`, `private_key.asc` | токены API, S3-креды, GPG |
| `mkdocs.yml` | базовый конфиг MkDocs (тема material, exclude_docs, extra) |
@@ -94,3 +94,73 @@ No Vultr IP deletion was performed in this work. The secondary address was only
## Important limitations
The measurements prove the route worked at test time. They do not guarantee permanent availability: NUBES, Vultr, upstream providers, or network filtering policy can change independently.
## Later the same day: optimisation attempt and its outcome
### Automation created
A reusable, idempotent tool was created outside this repository:
```text
/home/naeel/nubes/HowTo/vpn-transit/vpn-setup.sh check | apply | verify | passthrough | verify-passthrough | client-config | rollback
/home/naeel/nubes/HowTo/vpn-transit/client-config.json generated client config (chmod 600, contains UUID)
/home/naeel/nubes/HowTo/vpn-transit/README.md description, measurements, rollback
/home/naeel/nubes/HowTo/howto-vpn-transit-213-vultr-2026-09-28.md full report
```
Every change is preceded by a timestamped backup and followed by a config test (`nginx -t`, `xray run -test`) with automatic rollback on failure.
### Changes applied
| Host | File | Change | Backup |
|---|---|---|---|
| 213 | `/etc/nginx/sites-available/check.kube5s.ru` | `proxy_buffering off;` added inside `location /ws`, marked `# vpn-transit: proxy_buffering off` | `check.kube5s.ru.bak.1790601681` |
| Vultr | `/etc/v2ray-agent/xray/conf/00_log.json` | `loglevel`: `debug` → `warning` (log had grown to 76 MB), service restarted | `00_log.json.bak.1790601723` |
| 213 | `/usr/local/sbin/vpn-transit-dnat.sh`, `/etc/systemd/system/vpn-transit-dnat.service` | DNAT `213:8443 → 95.179.252.111:443` plus FORWARD rules, enabled at boot | none (rules tagged `vpn-transit`) |
### Measurements after the changes
- Outbound IP: `95.179.252.111`
- Throughput: `5.6–7.3 MiB/s` (10 MiB in 1.4–1.9 s)
- Per-connection latency: `0.23–0.37 s`
- WebSocket upgrade success rate on 213: `14569 / 14573` (99.97%), one `upstream timed out` error
### Hypothesis that was disproved: mux
`verify` compared the tunnel with and without `"mux": {"enabled": true, "concurrency": 8}`:
| Mode | 10 MiB download | Connection behaviour |
|---|---|---|
| without mux | 7.32 MiB/s in 1.43 s | stable |
| with mux | **0 B/s, failed** | after 4 requests connections hang for 15 s |
Conclusion: mux is harmful in the `VLESS + WebSocket behind nginx` combination. It is excluded from the client config. The test remains in the script for re-checking on future Xray versions.
### Optimisation that could not be delivered: removing the second TLS layer
The intended speed fix was to drop one TLS handshake (`client → 213`, then `213 → Vultr`) by forwarding TCP straight through to Vultr.
- `ngx_stream_module.so` is absent on 213, so nginx cannot do SNI-based passthrough without installing `libnginx-mod-stream`.
- Kernel-level DNAT on port 8443 was installed instead, but **does not work**: from outside, port 8443 returns `Connection refused` and the DNAT counter on 213 stays at 0 packets — traffic never reaches the machine.
- Cause: the provider firewall in front of 213 exposes only ports 80 and 443. Measured from `3060`: `3001, 8080, 8443, 8766, 8767, 8888, 18080, 40229` are closed.
- Therefore the second TLS layer can only be removed after the provider opens an additional port. The rules are already installed and would start working immediately once that happens.
### Errors made during this work
1. **Recommended `mux` before measuring it.** The recommendation was given as the main fix and was later disproved by measurement. Correct order: measure first, recommend after.
2. **Changed server configuration before measuring the benefit.** `proxy_buffering off` has no effect on a WebSocket connection after the `101 Switching Protocols` upgrade, and `loglevel` affects only log size. Neither change improves speed, so from the user's point of view nothing changed.
3. **Changed the client config to port 8443 before verifying the port was reachable from outside.** The config was regenerated back to port 443 immediately.
### Net result for the user
Nothing changed for the client: address `5.172.178.213`, port `443`, SNI `check.kube5s.ru`, path `/ws` and the UUID are unchanged, and the previously used link still works. No client-side reconfiguration is required.
The only actionable finding is client-side: the Xray log on Vultr contained **331** `connect: connection refused` to `127.0.0.1:45987`, i.e. the client requested a loopback address, plus Telegram advertises AAAA records while the tunnel is IPv4-only. The generated `client-config.json` addresses both (remote DNS, `queryStrategy: UseIPv4`), but the device itself was not modified.
Separately: **10170** `reset by peer` entries to `157.240.0.13` (Meta infrastructure) are blocking by those sites, unrelated to the transit.
### Scope audit (this action)
- Repository files changed: this document only. `git status` also showed unrelated pre-existing changes (`DEV_STAND/FullPipe/shturval.tf` deletion, `TMP/*` files) that were **not** touched or committed.
- Server-side files changed: as listed in the table above.
- Temporary test files on 3060: `/tmp/xray-test/*` (no permanent service installed).
@@ -0,0 +1,109 @@
# Правки ядра и модификаторов по итогам анализа 2026-09-30 (раунд Flash)
**Репо:** `/home/naeel/TF/tf_provider`. **Дата:** 2026-09-30.
**Источник заданий:** `NOTES/20_prompts/prompt_for_deepseek_pro_remediation_plan.md`
(гипотезы A1–A5, B1–B9) + диалог с Opus `HISTORY/OPUS/2026-09-30_provider_architecture_modifiers_dialog.md`.
---
## Что сделано (6 коммитов)
| Коммит | Пункт | Файлы | Суть |
|---|---|---|---|
| `047d53a` | C | `TOOLS/ARCHITECTURE.md` | Спека приведена к коду: ручные ресурсы, 401, удалённый `serviceSpecificModifiers`, раздел «Lifecycle Vocabulary». |
| `c5a4499` | B3 | `TOOLS/scripts/check_hardcoded_service_ids.sh`, `org_ip_allocation_resource.go` | Страж сканирует `TOOLS/` + `provider/internal/` (кроме `resources_gen/`), второй паттерн — литеральный ref-svc id. `ResolveRefSvcParamValue(ctx, 19, …)` → константа `svcIDVcOrg`. |
| `383f8ea` | A1 | `core/http.go`, `core/client_test.go`, `ARCHITECTURE.md` | 401 добавлен в `isRetryable` (действует для GET). Тест `TestIsRetryable`. |
| `ea75cac` | B6 | `core/modifier_compare.go`, `operation_run_bycode.go`, `nsxt_snat_resource.go` | Idempotency pre-check сравнивает с **live** (`state.params`), а не с `paramValue` формы. `setSnat` → `ByIdempotent`. Тест `TestModifierDesiredEqualsLive`. |
| `8519ba0` | B7 | `org_ip_allocation_resource.go`, `nsxt_snat_resource.go`, `org_ip_allocation_test.go` | `ImportState` заполняет Required (`vip_configure` из live/`[]`; `ip_space_name` из live/`no-needed`). Тесты на чистые хелперы. |
| `0e26e98` | — | `core/refsvc.go`, `docs/60_strategy/terraform_case_sensitivity_fix.md` | Убран устаревший комментарий про несуществующий блок «Restore user-provided casing»; §4 помечен как исторический. |
**Проверка после каждого коммита:** `go build ./...` OK, `go test ./internal/... -short` PASS,
`bash TOOLS/scripts/check_hardcoded_service_ids.sh` → OK.
**Бэкапы:** `TMP/backup_2026-09-30/` (исходные версии всех правимых файлов).
---
## Что ОТКЛОНЕНО после проверки по коду (важно)
- **A5 (нормализация регистра в `Read`) — был бы РЕГРЕССОМ.**
Принятое решение (проверено): state хранит регистр **пользователя**; ref_svc-атрибуты **исключены
из read-back** (шаблон `instance.go` добавляет `InputField` только при `eq .RefSvcId 0`); UUID
внутри JSON нормализуются при **отправке** (`resources_core.BuildJSON` →
`jsonutil.LowercaseUUIDsInText`). См. `docs/60_strategy/terraform_case_sensitivity_fix.md` §4 (пометка),
§10–§11. Нормализация state к lowercase сломала бы соответствие plan=config.
- **A3 (не обрывать modify при сбое live) — осознанная защита, а не дефект.**
`instanceLiveParams` намеренно возвращает ошибку: тихий fallback на `paramValue` (дефолт ФОРМЫ)
возвращает reset-баг (затирание параметров инстанса, HAR/edge_.har: `needEnableAVI`). Требуется
отдельное решение (см. Q2 промпта раунда 4).
- **A4 (угадывание типа по подстроке имени) — нужен замер.**
Fallback применяется только к required-параметру без `paramValue`/`defaultValue`
(`instance_create.go:105-113`) и при досылке modify. Гарантированного улучшения нет, риск сломать
больше, чем починить. Оставлено как есть.
---
## Отложено
- **A2 — retry POST.** Слепой ретрай создающего `POST /instanceOperations` опаснее обрыва
(дубликат операции). Решение — за владельцем (варианты в промпте раунда 4, Q1).
- **B8** — создавать ли оверлей `modifiers.yaml` или узаконить ручные модификаторы категорией в спеке.
- **B9** — единый словарь жизненного цикла (в спеку внесён как незакрытый вопрос; решение — Q4 промпта).
---
## Артефакты
- Промпт раунда 4 для Opus: `NOTES/20_prompts/prompt_for_opus_remediation_round4.md`
(5 коротких вопросов, лимит ответа ≤ 25 строк).
- Ограничение сессии: чат с Opus по раундам 1–3 исчерпан по токенам → раунд 4 в новом чате.
---
## Замер Q3 (2026-09-30): безопасно ли угадывание типа по имени?
**Источник:** `generated/dev/resources_yaml/*.yaml` (40 файлов), поля `data_type` / `required`.
**Метод:** подсчёт + эмуляция `normalizeUniversalValueV6` (ветка `nameHint`). Только чтение.
| Метрика | Значение |
|---|---|
| required-параметров всего | 852 |
| из них с пустым `data_type` | 5 |
| всего параметров с пустым `data_type` | 12 (~1.2 %) |
| из них угадывание по имени даёт ≠ `""` | **1** — `1_dummy.yaml` (`jsonExample` → `{}`), тестовый сервис |
Required с пустым `data_type` (все получают `""`; угадывание не срабатывает):
`120_clickhouse/delete:username`, `12_s3/create:resourceRealm`, `13_s3bucket/create:maxSize`,
`151_k8s_openbao/create:policyName`, `28_vc_vm_v3/create:userLogin`.
**Вывод.** Гипотеза A4 («риск неверной типизации» из-за подстроки имени) на dev-спеках
**не подтверждается**: для всех реальных сервисов угадывание по имени не срабатывает (итог `""`);
единственный эффект — тестовый `1_dummy.jsonExample`. То есть правка косметическая (упрощение),
а не исправление дефекта. Решение «снимать/оставлять» — за владельцем.
---
## Раунд 4 (Opus, новый чат) — решения и правки
Промпт: `NOTES/20_prompts/prompt_for_opus_remediation_round4.md` (5 вопросов, ответ ≤ 25 строк).
Ответ получен; ниже — что принято и что сделано.
| Q | Решение | Статус |
|---|---|---|
| Q1 retry POST | Не ретраить. `POST /instanceOperations` не идемпотентен, `Idempotency-Key` у API нет. | Зафиксировано в `TOOLS/ARCHITECTURE.md` (коммит `7a6f665`) |
| Q2 черновик операции | Отмены нет: `DELETE /instanceOperations/{uid}` отсутствует и в коде, и в HAR (проверено: `grep '"method": "DELETE"'` по `HAR/*.har` — ноль совпадений). Оставляем как есть, задокументировано. | `7a6f665` |
| Q3 zero-value по имени | **Закрыт замером**: угадывание не срабатывает (см. выше) — не дефект. | замер `54f036e` |
| Q4 словарь жизненного цикла | Единый контракт: `keep_on_destroy` + `suspend_on_destroy`; `delete_strategy` = маппинг (`noop_warn`→keep, `inverse`→destroy, `error`→валидация). | `7a6f665` |
| Q5 осиротевший инстанс | **Исправлено** (критичный). Ядро возвращает `instanceUid` вместе с ошибкой после создания; шаблон пишет partial state. | `9da9766` |
**Q5 детали:** `core/instance_create.go` — все ошибки ПОСЛЕ получения `instanceUid` возвращают
`instanceUid` (до создания — `""`); `templates/instance.go` — при `err != nil && id != ""` пишет
`data.ID` + `resp.State.Set` перед `AddError`. Регенерация dev (`02` + `dev-materialize`) → фикс в
40 файлах `resources_gen` (эфемерные, не в git). Тесты: `TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails`,
`TestCreateGenericInstance_EmptyUIDWhenInstanceCreateFails`.
**Коммиты раунда 4:** `54f036e` (замер), `9da9766` (Q5), `7a6f665` (Q1/Q2/Q4).
**Осталось:** замер владельцем (`terraform plan` ×2 на `DEV_STAND/FullPipe`); B8 (`modifiers.yaml`).
@@ -0,0 +1,89 @@
# 2026-09-30 — Очистка dev-реестра: удалены версии провайдера старше 2.0.21
> Команда владельца: «в деве — сотри ФИЗИЧЕСКИ все провайдеры старше 21 версии».
> Операция **необратимая** (бакет без версионирования), выполнена 2026-09-30.
## Что и где
| Параметр | Значение |
|---|---|
| Хранилище | S3 `https://s3.msk-1.ngcloud.ru`, бакет `nubes-terraform-registry` |
| Префикс | `tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/` |
| Стенд | **только dev** (`nubes-dev`); `nubes-test` и `nubes` не тронуты |
| Инструмент | `mc` (`/usr/bin/mc`), alias `prod-s3`, `--api S3v4`, креды из `secrets/.s3cfg_provider` |
| Версионирование бакета | `un-versioned` — удаление физическое, без «теневых» копий |
Состав одной версии — 5 объектов:
```
terraform-provider-nubes_<v>_darwin_amd64.zip (~13 MiB)
terraform-provider-nubes_<v>_linux_amd64.zip (~13 MiB)
terraform-provider-nubes_<v>_windows_amd64.zip (~13 MiB)
terraform-provider-nubes_<v>_SHA256SUMS
terraform-provider-nubes_<v>_SHA256SUMS.sig
```
## Было → стало
| | До | После |
|---|---|---|
| Версий | 25 (`2.0.0` … `2.0.24`) | **4** (`2.0.21`, `2.0.22`, `2.0.23`, `2.0.24`) |
| Объектов | 125 | **20** |
| Объём (zip) | ~975 MiB | ~156 MiB |
## Удалено (21 версия, 105 объектов)
```
2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7
2.0.8 2.0.9 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15
2.0.16 2.0.17 2.0.18 2.0.19 2.0.20
```
Каждая версия: 5 объектов (3 zip ~13 MiB + `SHA256SUMS` + `SHA256SUMS.sig`).
Команда (по версии):
```bash
mc rm --recursive --force \
"prod-s3/nubes-terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/<v>/"
```
Перед удалением снят полный манифест (125 строк):
```bash
mc ls -r "prod-s3/nubes-terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/"
```
## Оставлено
```
2.0.21 2.0.22 2.0.23 2.0.24 (20 объектов, 4 × 5)
```
Актуальная версия dev — `2.0.24` (см. `VERSIONS.md`).
## Проверки после удаления
| Проверка | Результат |
|---|---|
| `mc ls` префикса dev | только `2.0.21/`, `2.0.22/`, `2.0.23/`, `2.0.24/` |
| `mc ls -r` (всего объектов) | 20 (по 5 на версию) |
| API `…/nubes-dev/nubes/versions` | `['2.0.21','2.0.22','2.0.23','2.0.24']`, count 4 |
| Скачивание `2.0.24`/`2.0.23` (linux/amd64) | HTTP **206**, ZIP-магия `50 4b 03 04` |
| Скачивание `2.0.20`/`2.0.0` (linux/amd64) | HTTP **404** (объекта нет) |
| Контроль: `nubes-test` | `3.0.0` — не тронуто |
| Контроль: `nubes` (prod) | `1.0.0` — не тронуто |
> Примечание: эндпоинт `…/<v>/download/<os>/<arch>` отдаёт метаданные (JSON с `download_url`)
> **не проверяя наличие объекта** — статус 200 у него ничего не доказывает. Фактическая
> доступность проверяется загрузкой по `download_url` (как в таблице выше).
## Риски и восстановление
- ⛔ **Резервные копии zip не делались** — по прямому указанию «стереть физически»
(плюс канал до S3 из локальной сети медленный). Восстановление возможно **только
пересборкой** нужной версии из git-истории пакета;
`download_url`/`SHA256SUMS` удалённых версий не сохранялись.
- Пользователи, закрепившие в dev-стендах версии `< 2.0.21`, получат 404 при `terraform init`
и должны перейти на `2.0.21+`.
- `test` и `prod` не затронуты.
@@ -0,0 +1,60 @@
# 2026-09-30 — Перезаливка провайдера во все три стенда под версиями 1.0.0 / 2.0.0 / 3.0.0
> Команда владельца: «надо — чтобы в 1.0.0 2.0.0 3.0.0 стали НОВЫЕ провайдеры…
> ПОХУЙ на пользователей! ПОХУЙ на старые версии!!! генери всё новое и ЗАЛИВАЙ».
## Что сделано
Полный цикл по каждому стенду: перегенерация (`01` YAML → `02` Go+доки) и
сборка+подпись+заливка (`03`) — всё одной командой `03` (она сама вызывает `01` и `02`).
| Стенд | Namespace | Версия | `VERSION` в profile.env | Результат |
|---|---|---|---|---|
| prod | `nubes` | `1.0.0` | `1.0.0` (без изменений) | `Done. Version 1.0.0 uploaded.` |
| dev | `nubes-dev` | `2.0.0` | `2.0.24` → **`2.0.0`** | `Done. Version 2.0.0 uploaded.` |
| test | `nubes-test` | `3.0.0` | `3.0.0` (без изменений) | `Done. Version 3.0.0 uploaded.` |
Команды:
```bash
export MC_CONFIG_DIR=/tmp/mc-cfg
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.0
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/test 3.0.0
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/prod 1.0.0
```
Что делает `03`: `01` (YAML-спеки стенда) → `02` (Go-ресурсы + доки) → сборка
3 платформ (`linux/windows/darwin amd64`, `CGO_ENABLED=0`, `-ldflags=-X main.version=…`)
→ `terraform-provider-nubes_<v>_SHA256SUMS` → GPG-подпись (`secrets/private_key.asc`,
`CB3A0DF161ECC416`) → заливка 5 объектов в S3.
## Проверки после заливки
| Стенд | Версия | `linux/amd64` скачивание | sha256 залитого vs локальной сборки |
|---|---|---|---|
| dev | `2.0.0` | HTTP 200, 13 169 112 B | ✅ совпадает |
| test | `3.0.0` | HTTP 200, 13 095 908 B | ✅ совпадает |
| prod | `1.0.0` | HTTP 200, 13 103 414 B | ✅ совпадает |
Дополнительно:
- API `/versions`: `nubes-dev` → `['2.0.0','2.0.21','2.0.22','2.0.23','2.0.24']`,
`nubes-test` → `['3.0.0']`, `nubes` → `['1.0.0']`;
- в S3 у каждой версии ровно 5 объектов (3 zip + `SHA256SUMS` + `SHA256SUMS.sig`).
## ⚠️ Последствия (приняты владельцем сознательно)
- Версии `1.0.0`, `2.0.0`, `3.0.0` **перезаписаны** — под теми же номерами теперь
другие бинарники. У всех, у кого есть `.terraform.lock.hcl`, будет
`checksum mismatch` при `terraform init`; лечится `terraform init -upgrade`.
- Старые версии **не удалялись** (кроме ранее вычищенного dev `< 2.0.21`):
в dev остаются `2.0.21`–`2.0.24`, в test `3.0.0` и в prod `1.0.0` — теперь уже как
свежие сборки.
- `VERSION` в `TOOLS/config/dev/profile.env` понижен `2.0.24` → `2.0.0`
(чтобы доки и артефакты генерировались с новой версией); закоммичено.
## Связанные документы
- `VERSIONS.md` — обновлённая таблица текущих версий.
- `HISTORY/2026-09-30_dev_registry_prune_versions.md` — предыдущая очистка dev-реестра.
- `HISTORY/2026-09-30_yaml_pipeline_hardening.md` — состояние пайплайна генерации.
@@ -0,0 +1,130 @@
# 2026-09-30 — Регистр UUID: нормализация на ОТПРАВКЕ в API (create/modify/redeploy)
> Разбор: `docs/60_strategy/terraform_case_sensitivity_fix.md` §11 (главный документ по теме),
> `NOTES/30_analysis/ARCHITECTURE_NEW.md` §6.5.
## Что обнаружилось
Костыль `lower(...)` в конфиге стенда Штурвала — **не «просто проще», а обязателен**.
Без него `terraform apply` (create кластера) падает: платформа отвечает
«Edge не развёрнут в указанном vDC».
Обнаружено при запуске Terraform **из-под Windows**, на провайдере **2.0.23**
(то есть после всех «фиксов регистра», выпущенных 24.09).
Костыль живёт в примере (и в gitea `Nail/tf_examples`):
```hcl
# tf_examples/fullpipe_chain/shturval.tf:139-140
vdc_uid = lower(nubes_vc_vdc.vdc.id)
nsxt_uid = lower(nubes_vc_nsxt.edge.id)
```
## Почему прошлые фиксы не помогли (главная мысль)
Провайдер `2.0.23` нормализует регистр UUID **только при СРАВНЕНИИ**:
план vs state, adopt/suspend/resume, modifier-compare, диагностика
(`jsonutil.LowercaseUUIDsInText` → `JSONStringsEquivalent`, `JsonNormalize()`,
`ParamsMatchForResume`, `normalizeCompareValue`).
**Путь ОТПРАВКИ в API остался без нормализации.** Все map-fixed JSON-параметры
собираются одной функцией `resources_core.BuildJSON`
(`provider/internal/resources_core/helpers.go`), а её вызывает сгенерированный код
(`NestedJSONExpr`, шаблон `TOOLS/resource-generator/internal/templates/instance.go`,
ветки Create / Modify / Redeploy). `BuildJSON` берёт `ValueString()` подполей **как есть**.
Ресурс `nubes_vc_nsxt` отдаёт `id` в UPPERCASE (`2C37FED1-…`), платформа хранит
UUID в lowercase и **сравнивает регистр при create** → `startupConfiguration.nsxtUid`
в верхнем регистре отвергается.
Почему не спас `resolveRefSvcParamValues` (`core/refsvc.go`,
`core/refsvc_resolve.go`): он нормализует только **top-level** refSvc-параметры и
`s3.*uid` **внутри** map-fixed. `vdcUid`/`nsxtUid` — обычные строковые подполя
JSON, refSvcId у них нет, под шаблон `s3.*uid` они не подпадают.
## Что сделано
| Файл | Изменение |
|---|---|
| `provider/internal/resources_core/helpers.go` | `BuildJSON` оборачивает результат в `jsonutil.LowercaseUUIDsInText(...)` (+ импорт `core/jsonutil`, комментарий-обоснование) |
Одна точка → покрыты **все** map-fixed-параметры всех ресурсов на
create / modify / redeploy (19 сгенерированных ресурсов, `resources_gen/`).
Регенерация не требуется (логика сериализации одна).
## Оценка риска (почему это безопасно)
- `BuildJSON` используется **только для отправки** в API, не для построения state.
- Regex `uuidAnywhereRegex` = `[0-9a-f]{8}-xxxx-xxxx-xxxx-xxxxxxxxxxxx` — совпадает
только с UUID; пароли/имена/произвольные строки не задевает.
- Проверено по спекам: внутри map-fixed **нет** строковых секретных полей
(password/secret/token) — только `*Uid`-ссылки на ресурсы.
- Это **выравнивание** с уже принятым в провайдере правилом «регистр UUID незначим»
(то же приведение уже делается на сравнении), а не новое поведение.
Остаточный риск: если в map-fixed когда-нибудь появится строковое поле, где
пользователь хранит **свой** UUID, и регистр там семантически важен (не ссылка на
ресурс) — он будет приведён к lowercase. Сейчас таких полей нет.
## Следствия
- `lower(...)` в HCL становится **не нужен** — убирать в конфигах и в примере
(отдельной командой, после релиза провайдера).
- **Выпущено 30.09.2026: dev `2.0.24`** — собрано (linux/windows/darwin amd64),
подписано GPG и залито в реестр (`nubes-dev/nubes/2.0.24/`), версия видна
в `/v1/providers/nubes-dev/nubes/versions`. `VERSIONS.md` обновлён.
Доки в реестр (шаг `04_build_and_publish_docs.sh`) **не публиковались**.
- В локальных стендах костыля нет: `DEV_STAND/FPipeGmail/shturval.tf:125-126` и
`DEV_STAND/FullPipe/shturval.tf1:123` передают `nubes_vc_vdc.vdc.id` /
`nubes_vc_nsxt.edge.id` напрямую → на create у них тот же риск.
## Процедура релиза (dev) — воспроизводимо (проверено 30.09.2026)
1. Поднять `VERSION` в `TOOLS/config/dev/profile.env` и закоммитить
(иначе доки генерируются со старой версией).
2. Собрать и залить:
```bash
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.24
```
Скрипт сам выполняет шаги `01` + `02`, собирает 3 платформы
(linux/windows/darwin amd64), подписывает GPG и заливает в S3.
3. Проверить публикацию:
```bash
curl -s https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/nubes-dev/nubes/versions
```
4. Обновить `VERSIONS.md` и закоммитить.
5. Для локального `go build`/`go test`: `02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev`
и `TOOLS/scripts/dev-materialize.sh dev` (эфемерная копия в `provider/`).
⚠️ Доки в реестр (шаг `04_build_and_publish_docs.sh`) в этом релизе **не публиковались**.
## Где нормализация нужна (карта, чтобы не потерять)
Нормализация регистра UUID нужна в **двух независимых местах**:
1. **Сравнение** (план ↔ state, adopt, suspend/resume, modifier-compare, диагностика):
`jsonutil.LowercaseUUIDsInText` → `JSONStringsEquivalent`, `JsonNormalize()`,
`ParamsMatchForResume`, `normalizeCompareValue`.
2. **Отправка в API** — единственная точка `resources_core.BuildJSON`
(`provider/internal/resources_core/helpers.go`), вызывается сгенерированным кодом
через `NestedJSONExpr` (`TOOLS/resource-generator/internal/templates/instance.go`:
Create ~302, Modify ~488, Redeploy ~505).
Правила:
- ⛔ `lower(...)` в HCL — костыль, а не решение (был нужен только из-за ненормализованной отправки).
- ⛔ Не нормализовать план целиком (скаляры→строки, сортировка ключей) — вечный diff;
менять только регистр UUID-подстрок.
- `resolveRefSvcParamValues` (`core/refsvc.go`) покрывает только top-level `refSvcId`
и `s3.*uid` внутри map-fixed; `vdcUid`/`nsxtUid` — нет.
- Спеки map-fixed без строковых секретов (только `*Uid`) → regex `uuidAnywhereRegex` безопасен.
## Открытые вопросы (не закрыты)
1. Проверить на живом стенде: create кластера Штурвала **без** `lower(...)` на сборке
с этим фиксом — `apply` запускает только пользователь.
2. `core/params.go` → `normalizeUniversalValueV6`: скалярные UUID, попадающие в
дефолты create (`instance_create.go`) и в досылку modify (`operation_run.go`),
к lowercase не приводятся (вторично, нужен замер).
3. `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`): ref-параметр
внутри JSON не валидируется при adopt (открыто с 24.09).
@@ -0,0 +1,258 @@
# 2026-09-30 — Ужесточение пайплайна генерации YAML (безопасная замена, чистка легаси)
> Связанные материалы:
> `TOOLS/README.md` (канонический пайплайн, порядок шагов),
> `TOOLS/ARCHITECTURE.md` (спецификация),
> память репозитория: `pipeline-legacy.md`.
## Задача
1. Разобрать, что в генерации YAML устарело.
2. Перегенерировать YAML по всем стендам так, чтобы **старое удалялось безопасно**,
а новое создавалось атомарно.
3. Задокументировать всё, чтобы история изменений прослеживалась.
Команда пользователя: «делай как ПОЛОЖЕНО, как в best practices».
## Что было не так (до правок)
### `TOOLS/scripts/01_generate_yamls.sh`
| Место (до) | Проблема |
|---|---|
| стр. 230 `rm -f "$output_glob"` | старый YAML удалялся **до** генерации → при сбое API файл исчезал, новый не создавался (неатомарно per-service) |
| стр. 122 «Полной очистки нет» | YAML исключённого сервиса оставался в каталоге и попадал в сборку |
| стр. 91 `ls -t "${ROOT_DIR}"/*.token` | легаси-фолбэк токена: в корне токенов нет; поиск «последнего» мог подхватить чужой токен |
| стр. 109 `API_ENDPOINT="${NUBES_API_ENDPOINT:-https://lk-api-gateway.ngcloud.ru/...}"` | молчаливый уход в **PROD**, если в профиле нет endpoint |
| стр. 167 `svc_name=""` | имя всегда пустое, хотя шапка обещала парсинг из списка → лишний HTTP-запрос на каждый сервис (2 запроса вместо 1) |
| стр. 46–50 | дубль `SERVICES_FILE_DEFAULT` (одинаковое присваивание в `if`) |
| шапка vs код | «REQUEST_DELAY по умолчанию 0.2», в коде 0.5; путь вывода указан как `provider/resources_yaml` (устарел) |
### `TOOLS/yaml-generator/internal/config/config.go`
| Место (до) | Проблема |
|---|---|
| `Load()` стр. 33 | свой дефолт endpoint = **PROD** gateway |
| `loadToken()` + `findLatestToken()` | легаси-фолбэк «последний `*.token` в корне репо» |
| `Load()` стр. 60 | путь `filepath.Join(repoRoot, "devops", "config", "services_list.txt")`, причём `FindRepoRoot()` возвращает каталог `provider/` → путь заведомо не существовал |
### Легаси-скрипты
`10_yaml_stability_run.sh`, `11_yaml_stability_run_latest.sh`,
`12_generate_yamls_latest.sh`, `13_generate_yamls_clean.sh`,
`02_generate_resources_and_docs_template_v2.sh` — **мертвы**: зовут `01` без
`--profile` (→ `exit 2`), ищут `*.token` в корне репо, а `13` вдобавок делал
`rm -f provider/resources_yaml/*.yaml`. Ни один рабочий скрипт их не вызывает
(ссылки есть только в исторических `HISTORY/`, `NOTES/`).
## Что сделано
### 1. `01_generate_yamls.sh` — безопасная запись по принципу staging → атомарная замена
Новый алгоритм:
```
staging = generated/<stand>/resources_yaml.staging.<pid>
↓ генерация всех сервисов пачкой per-service в staging
↓ при пустом failures:
mv resources_yaml → resources_yaml.bak-<UTC> (бэкап, ротация KEEP_BACKUPS=5)
mv staging → resources_yaml (атомарный rename в том же FS)
↓ при непустом failures:
замена ОТМЕНЯЕТСЯ, рабочий каталог не тронут, staging оставлен для разбора, exit 1
```
Прочие изменения:
- каталог помечается маркером `.stand`; генерация в каталог чужого стенда запрещена (`exit 2`);
- `embed.go` создаётся теперь в staging (обязательный `go:embed *.yaml`);
- `NUBES_API_ENDPOINT` обязателен, иначе `exit 2`;
- токен берётся только из `NUBES_API_TOKEN`/`TOKEN_FILE`; легаси-поиск удалён;
- имя сервиса — из 2-го поля `services_list.txt`; лишний python-запрос удалён;
- удалён дубль `SERVICES_FILE_DEFAULT`; синхронизированы комментарии.
### 2. `yaml-generator/internal/config/config.go`
- `NUBES_API_ENDPOINT` обязателен (нет PROD-дефолта);
- `loadToken()` больше не ищет `*.token` в корне репо; `findLatestToken` и `getenvDefault` удалены как мёртвые;
- при отсутствии `NUBES_SERVICE_ID` требуется явный `NUBES_SERVICES_FILE` (угадывание пути удалено).
### 3. Легаси-скрипты отключены (fail-fast)
В начало каждого добавлен guard: сообщение `DEPRECATED` + `exit 2`. Файлы **не удалены**
(удаление — отдельное решение владельца), но теперь они не могут сделать ничего вредного.
### 4. Документация
- `TOOLS/README.md` — добавлен раздел «Канонический пайплайн (порядок шагов)» и описание безопасной генерации;
- `TOOLS/ARCHITECTURE.md` — ссылки `devops/…` заменены на `TOOLS/config/<stand>/…`;
- память репозитория — `pipeline-legacy.md` уточнена.
## Прогон по всем стендам (результат)
Токены проверены прямыми запросами к API (с браузерным `User-Agent`, иначе DDoS-Guard отдаёт 403):
| Стенд | Endpoint | HTTP | YAML после генерации | Stale | Failures |
|---|---|---|---|---|---|
| dev | `lk-api-gateway-dev.ngcloud.ru` | 200 | 40 | нет | нет |
| test | `lk-api-gateway-test.ngcloud.ru` | 200 | 36 | нет | нет |
| prod | `lk-api-gateway.ngcloud.ru` | 200 | 35 | нет | нет |
Команды:
```bash
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/test
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/prod
```
Проверка целостности: `diff -rq` нового каталога dev с бэкапом даёт различия только
в случайных `default`-суффиксах, которые API генерирует при каждом запросе
(`db-ievgpdvu` → `db-ujama5rb`, `flask-seqtiq3t` → `flask-xwfdxdqh` и т.п.) —
структурной регрессии нет. Это же объясняет, почему побайтовое сравнение двух
прогонов не может быть использовано как «детектор дрейфа».
## Коммиты
- `12b3932` — `fix(tools): безопасная генерация YAML — staging + атомарная замена, без легаси-фолбэков`
- `ad4daab` — `chore(tools): легаси-скрипты генерации отключены (fail-fast DEPRECATED)`
## Проверки
- `bash -n` для `01_generate_yamls.sh` и всех guard-скриптов — OK;
- `go vet ./...` + `go build` для `yaml-generator` — OK;
- guard отдаёт `exit 2`;
- прогон dev/test/prod — 0 failures, stale отсутствует, staging не остаётся;
- бэкапы создаются: `generated/dev/resources_yaml.bak-20260930T063152Z` и т.д.
## Этап 2 — удаление мёртвого (по команде «удаляй всё старое, аккуратно»)
**Удалено (`1e796c8`)** — 100% мёртвый код/данные, ничего их не вызывает:
| Файл | Почему удалён |
|---|---|
| `TOOLS/scripts/10_yaml_stability_run.sh` | зовёт `01` без `--profile` (exit 2), ищет `*.token` в корне |
| `TOOLS/scripts/11_yaml_stability_run_latest.sh` | цепочка на `10`, та же поломка |
| `TOOLS/scripts/12_generate_yamls_latest.sh` | зовёт `01` без `--profile`, ищет `*.token` в корне |
| `TOOLS/scripts/13_generate_yamls_clean.sh` | цепочка на `12` + делал `rm -f provider/resources_yaml/*.yaml` |
| `TOOLS/scripts/02_generate_resources_and_docs_template_v2.sh` | легаси-дубль канонического `02_generate_resources_and_docs_v2.sh` |
| `TOOLS/config/services_list.txt` (общий) | код его не читает; как «объединение» устарел: активный `27` (в test/prod — «нет в UI»), нет `87/88/97/153`, которые есть в dev |
Проверка «ничего не вызывает»: `grep` по всему репо находил ссылки только в
исторических `HISTORY/`, `NOTES/`, `docs/` (не исполняются).
**Правки ссылок (`c822ae2`)**: `README.md`, `HOW_TO/README.md`,
`HOW_TO/DEVOPS_BUILD_PIPELINE.md`, `HOW_TO/HOWTO_ADD_NEW_SERVICE.md` (включая
переписанный блок «Быстрый старт» с `devops/` на `./TOOLS/scripts/*`),
`DOCS_PIPELINE/README.md`, `scripts/publish-doc-page.sh`, `.gitignore`.
**Проверка после удаления**: `bash -n` для всех `TOOLS/scripts/*.sh` и
`scripts/publish-doc-page.sh` — OK; smoke-прогон `01 --profile TOOLS/config/dev` —
40 YAML, замена атомарная, бэкап создан.
**Не удалено (осознанно):**
- поддержка легаси-прокси `index.cfm` в `01` и `yaml-generator` — это совместимость
с работающими пользователями старого API (провайдер v5.0.75, `secrets/stands.md`);
- `DOCS_PIPELINE/publish-docs.sh` — сам файл помечен «справочная копия, не подменяет пайплайн»;
- `HISTORY/`, `NOTES/`, `docs/` — исторические документы (в них `devops/` и легаси-скрипты
упоминаются как история, это нормально);
- `scripts/*.py` и `s3_notification_example.sh` — ручные утилиты, вызываются вручную.
## Этап 3 — сверка списков стендов с облачным каталогом (источник истины)
Принято: **истина — то, что перечислено в облаке**. Определяется эндпоинтом каталога:
```bash
# «перечислено в облаке» (продакшен-готовые сервисы стенда)
GET {NUBES_API_ENDPOINT}/services?limit=200&isProductionReady=true
# для сравнения: без фильтра отдаются ВСЕ сервисы платформы, включая
# DEPRECATED и не заявленные в каталоге (48 у prod, 49 у test, 60 у dev)
```
Требуется браузерный `User-Agent` (иначе DDoS-Guard отдаёт 403) и `Referer`.
Результат на 2026-09-30:
| Стенд | Облако (`isProductionReady=true`) | Активных в `services_list.txt` | Лишние в файле | Не хватало |
|---|---|---|---|---|
| dev | 40 | 40 | нет | нет |
| test | 36 | 36 | нет | нет |
| prod | 36 | 35 → **36** | нет | **`151 k8sOpenbao` (Vault)** |
У остальных 12 закомментированных prod-сервисов, присутствующих в API, `isProductionReady=false` —
они закомментированы обоснованно. Четыре id в файле отсутствуют в каталоге prod вовсе
(`32 vmpostgre`, `87 k8svalkey`, `153 nifi`, `175 k8sGo`).
Исправлено коммитом `a68a36a`: `151 k8sOpenbao` раскомментирован (комментарий «нет в PROD UI»
устарел), prod перегенерирован — 36 YAML, ровно как в облаке.
## Этап 4 — перепроверка после полной перегенерации + подводные камни
Команда: «сгенери YAML для всех стендов, проследи чтобы старого ничего не осталось,
перепроверь после генерации всё». Выполнено три прогона `01`:
```bash
for s in dev test prod; do ./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/$s; done
# все три: exit=0
```
### Результат перепроверки (2026-09-30)
| Стенд | YAML | = активных в списке | = облако (`isProductionReady=true`) | Stale | Дубли id | Failures |
|---|---|---|---|---|---|---|
| dev | 40 | ✅ 40 | ✅ 40 | нет | нет | пусто |
| test | 36 | ✅ 36 | ✅ 36 | нет | нет | пусто |
| prod | 36 | ✅ 36 | ✅ 36 | нет | нет | пусто |
Дополнительно проверено:
- staging-каталоги (`resources_yaml.staging.*`) — не осталось ни одного;
- в `resources_yaml/` только `*.yaml`, `.stand`, `embed.go` — посторонних файлов нет;
- `.stand` в каждом каталоге совпадает с профилем (`dev`/`test`/`prod`);
- бэкапы прошлых версий: dev 3, test 2, prod 2 (ротация `KEEP_BACKUPS=5`);
- `generated/<стенд>/tmp/yaml_gen_failures.txt` — пусты;
- `git status` — чисто.
### ⛔ Подводные камни, найденные при перепроверке (важно на будущее)
1. **API отдаёт случайные `default`.** Часть параметров приходит со случайным
суффиксом (`db-ievgpdvu` → `db-ujama5rb`, `kvname-grzjes7l` → `kvname-g3s0uof2`,
`flask-seqtiq3t` → `flask-xwfdxdqh`). Поэтому **побайтовое сравнение двух прогонов
не является детектором дрейфа** — различия в этих строках не регрессия.
2. **Случайный `default` вшивается в сгенерированный Go-код.**
Пример: `generated/dev/go/151_k8s_openbao_kv_resource.go` содержит
`Default: stringdefault.StaticString("kvname-XXXX")`. Следствие:
`check_generated_drift.sh dev` показывает **дрейф 15 файлов сразу после любой**
перегенерации YAML — это не ошибка оператора.
3. **Производные артефакты стареют молча.** `generated/<стенд>/go` и `generated/<стенд>/docs`
создаются шагом `02` и после нового `01` становятся старше своих источников
(на момент проверки: `go`/`docs` dev — 08:46, YAML dev — 10:18). Отдельно живёт
эфемерная копия `provider/internal/resources_gen` + `provider/resources_yaml`
(её кладёт `dev-materialize.sh`, маркер `.stand` = стенд). Их нужно обновлять
шагом `02` после каждого `01`.
4. **Прямые HTTP-запросы к API без браузерного `User-Agent` получают 403**
(DDoS-Guard). С `User-Agent` + `Referer` — 200.
### Актуальная карта пайплайна на 2026-09-30
- Единственный путь генерации YAML: `TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/<стенд>`
(`--profile` обязателен, без него `exit 2`).
- Один универсальный движок на все стенды: `TOOLS/bin/yaml-generator`, стенд задаётся
переменными окружения (`NUBES_API_ENDPOINT`, `NUBES_API_TOKEN`, `NUBES_SERVICE_ID`,
`NUBES_SERVICE_NAME`, `NUBES_OUTPUT_DIR`); список сервисов — свой у каждого стенда.
- Стенд-специфичных хардкодов в коде нет — контролируется `check_hardcoded_service_ids.sh`.
- Токены: `secrets/{dev,test,prod}.token` (валидны на 2026-09-30, срок до 2026-12-27);
обновление — `TOOLS/scripts/00_token_manager.sh` (keycloak refresh, `THRESHOLD_MIN=10`).
- Удалены как мёртвые (`1e796c8`): `10/11/12/13_yaml_*.sh`,
`02_generate_resources_and_docs_template_v2.sh`, общий `TOOLS/config/services_list.txt`.
- Оставлены осознанно: поддержка легаси-прокси `index.cfm`, справочная копия
`DOCS_PIPELINE/publish-docs.sh`, исторические `HISTORY/`/`NOTES/`/`docs/`,
ручные утилиты `scripts/*.py`.
## Открытые вопросы (на решение владельца)
1. Поддержка легаси-прокси `index.cfm`: оставляем или выпиливаем (README уже помечает
закрытые API как «не использовать»)?
2. Прочие `.gitignore`-паттерны мёртвых каталогов (`universal_rebuild/*`, `provider/generated/`)
— чистить?
@@ -0,0 +1,933 @@
# Диалог: Opus 4.8 — анализ архитектуры провайдера и ресурсов-модификаторов (2026-09-30)
> **Что это.** Полная, без сокращений, запись диалога: постановка задачи пользователем,
> ход работы агента (GitHub Copilot) до создания файла-промпта, содержимое промпта,
> ответ Opus 4.8 (приведён дословно в том виде, как получен) и открытый вопрос Opus.
> **Формат.** Хронология ходов. Реплики пользователя и ответ Opus — дословно, включая опечатки.
> **Статус.** Диалог НЕ завершён: Opus задал вопрос, ответ ему ещё не передан.
---
## Ход 1 — Задание пользователя (дословно)
```
составь промпт в файл для Опус 4.8
пусть проанализирует архитектуру провайдера ... с дополнительными ресурсами-модификаторами
ограничь зону его доступносьти - пусть НИЧЕГО не читает из хистори, только файл с архитектурой
и точно выдай ему список файлов которые надо изучить, более пусть никуда не лезет
если что непончятно - пусть напишет тебе вопрос, я тебе передам
взаимодейтвуйте в редиме диалога, НО НЕ НАДО злоупотреблять !
ответы от него - МАКСИМАЛЬНО СЖАТО, без лирики и воды, лишь бы ты сам как агент понял
```
Контекст задания: активный файл — `DEV_STAND/FullPipe/shturval.tf1`; активное выделение — строка 117
(`suspend_on_destroy`).
---
## Ход 2 — Работа агента до создания файла (разведка, только чтение)
Поиск архитектурных документов и существующих промптов для Opus:
- `NOTES/20_prompts/` — 20 файлов промптов; среди них `prompt_for_opus_modifier_architecture_full.md`,
`prompt_for_opus_modifiers_review.md`, `prompt_for_opus_review_modify_resources_2026-09-24.md`,
`prompt_for_opus_modifier_global_architecture.md`. Часть помечена баннером ⛔ «ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО».
- `HISTORY/OPUS/` — 16 файлов диалогов с Opus (напр. `2026-09-22_modifier_architecture_project.md`).
- Кандидаты на «файл архитектуры»: `TOOLS/ARCHITECTURE.md`, `NOTES/30_analysis/ARCHITECTURE_NEW.md`,
`docs/60_strategy/provider_philosophy.md`, `docs/60_strategy/modifier_resources_ideology_and_specification.md`.
Прочитано:
- `TOOLS/ARCHITECTURE.md` (246 строк) — в первых строках помечен «PRIMARY SOURCE OF TRUTH»,
«THIS FILE IS THE FOUNDATION».
- `NOTES/30_analysis/ARCHITECTURE_NEW.md` — «Universal Rebuild — Архитектура и рабочая цепочка»
(внутри — ссылки на пути `/home/naeel/terra/universal_rebuild`, т.е. иная раскладка репозитория).
- `NOTES/20_prompts/prompt_for_opus_modifier_architecture_full.md`, `…_modifiers_review.md`,
`…_review_modify_resources_2026-09-24.md`.
- `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md` — карта файлов, разделение «актуально / LEGACY».
Проверка актуальности через git:
```
git log -3 -- TOOLS/ARCHITECTURE.md
cbd559d 2026-09-30 docs(tools): канонический пайплайн + история ужесточения генерации YAML
78f9dfb 2026-09-22 refactor(build): эфемерные generated-копии …
c14f7de 2026-09-22 docs: раздел «Реестр исключений» + диалог код-ревью opus/astra
```
Установленная фактическая реализация модификаторов (на 2026-09-30):
- База: YAML на сервис — «чистая выгрузка» из API, без доменных меток. В `TOOLS/yaml-generator/main.go`
(комментарий перед `CollectOperations`) прямо сказано: реестр `serviceSpecificModifiers`, ранее
помечавший `modify` как `kind=modifier`, удалён при рефакторинге 2026-09-23; модификаторы теперь —
«отдельный оверлей (см. `modifiers.yaml` + второй проход)», базовый YAML о них не знает.
- Реальные модификаторы — РУЧНЫЕ Go-ресурсы:
- `provider/internal/resources_core/org_ip_allocation_resource.go` —
`nubes_vc_org_ip_allocation` (сервис 19, `modify`, `vIPConfigure` id 662);
- `provider/internal/resources_core/nsxt_snat_resource.go` —
`nubes_vc_nsxt_snat` (сервис 22, `modify`, `ipSpaceName` id 372; канон-значение `no-needed`).
- Регистрация: `provider/internal/provider/provider.go`, функция `Resources()` (строки 182–190) —
`append(resources, resources_core.NewOrgIpAllocationResource, resources_core.NewNsxtSnatResource)`.
- Вызываемые методы ядра: `RunInstanceOperationUniversalByCode` (`provider/internal/core/operation_run_bycode.go`),
`GetInstanceStateParams` (`provider/internal/core/instance_params.go`),
`LockInstance`/`UniversalClient` (`provider/internal/core/client.go`),
`ResolveRefSvcParamValue` (`provider/internal/core/refsvc_resolve.go`),
`ShouldRemoveFromState` (`provider/internal/resources_core/resource_diagnostics.go`).
- Спеки операций/параметров: `generated/dev/resources_yaml/19_vc_org.yaml`, `22_vc_nsxt.yaml`.
- Пример применения цепочки: `DEV_STAND/FullPipe/modifiers.tf`.
Агент задал пользователю 3 уточняющих вопроса (какой файл считать «архитектурой»; что именно должен
выдать Opus; имя файла промпта) — **пользователь вызов отклонил (cancelled)**.
---
## Ход 3 — Ответ пользователя (дословно)
```
думай сам ! возможно несколько файлов ему дать
```
---
## Ход 4 — Решение агента и создание файла
Агентом принято самостоятельно (без дополнительных вопросов):
- «Файл с архитектурой» — единственный: `TOOLS/ARCHITECTURE.md`.
- Формат выхода Opus — архитектурный разбор: устройство по слоям, спека ↔ код, жизненный цикл
модификаторов, риски с указанием `файл:строка`.
- Путь промпта — `NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers.md`
(соответствует конвенции папки `NOTES/20_prompts/`).
Проверка существования всех 22 путей из списка (скрипт `test -f` по каждому) — результат:
`ALL FILES PRESENT`. Отсутствующих путей нет.
Коммит: `752244f` —
`docs(prompt): промпт для Opus 4.8 — анализ архитектуры провайдера + ресурсов-модификаторов`.
### Содержимое созданного файла `NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers.md` (дословно)
```markdown
# Промпт для Opus 4.8 — анализ архитектуры провайдера + ресурсов-модификаторов
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
**Дата:** 2026-09-30. **Стенд-источник фактов:** `dev`.
---
## Роль и режим работы
- Ты — архитектор/ревьюер универсального Terraform-провайдера.
- Работаем **в диалоге**: я (агент) передаю твои вопросы пользователю и возвращаю его ответы.
- Вопрос задавай ТОЛЬКО если без него ответить нельзя. Максимум 1–2 вопроса за раз, предельно коротко.
- Не догадываться. Нет данных — вопрос, а не допущение.
- Область не расширять: отвечать ровно на поставленную задачу.
## Задача
Проанализировать архитектуру универсального провайдера Nubes и встроенный в неё слой
**ресурсов-модификаторов** — отдельных ресурсов, которые вызывают операцию `modify`
у родительского инстанса (когда нужного параметра нет в операции `create`).
Оценить:
1. Как устроена архитектура по слоям и как течёт поток данных (API → YAML → код → API).
2. Соответствие заявленной спеки (`TOOLS/ARCHITECTURE.md`) фактической реализации — все
расхождения, с указанием `файл:строка`.
3. Корректность жизненного цикла модификаторов: `Create` / `Read` / `Update` / `Delete`,
идемпотентность, дрейф (drift), поведение при `replace` / повторном `apply`, импорт.
4. Место модификаторов в универсальном ядре: где и как нарушается принцип
«ядро универсально, доменные знания — только данные». Насколько оправдано текущее
решение (ручные Go-ресурсы, зарегистрированные поверх генерируемых).
5. Границы ответственности: что модификатор делает сам, что отдаёт платформе; как
выражается обратная операция (откат при `destroy`, значение «выключено»).
6. Риски и топ-проблемы — по убыванию критичности, каждое с `файл:строка`.
## Границы доступа (ЖЁСТКО)
Читать РАЗРЕШЕНО **только** файлы из списка ниже. Всё остальное — ЗАПРЕЩЕНО, в частности:
- `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `docs/**`, `site/**`, `site_test/**`,
`apps/**`, `charts/**`, `FIYR_MGU/**`, `gateway/**`, `scripts/**`, `secrets/**`,
`tfflaskcrud/**`, `tfluceecrud/**`, `tfnodejscrud/**`, `DEV_STAND/**` (кроме одного файла
из списка), `TEST_STAND/**`, `PROD_STAND/**`, `provider/artifacts/**`, `provider/bin/**`;
- история git (`git log`, `git show`, `git diff` с коммитами), коммиты, теги, ветки;
- любой файл репозитория, которого нет в списке ниже.
Нужен файл вне списка → НЕ читать, а задать мне вопрос.
## Файлы к изучению (исчерпывающий список)
### Группа 1. Архитектура (спека)
- `TOOLS/ARCHITECTURE.md`
### Группа 2. Ресурсы-модификаторы и их регистрация
- `provider/internal/provider/provider.go`
- `provider/internal/resources_core/org_ip_allocation_resource.go`
- `provider/internal/resources_core/nsxt_snat_resource.go`
- `provider/internal/resources_core/org_ip_allocation_test.go`
### Группа 3. Рантайм-зависимости модификаторов (ядро)
- `provider/internal/core/client.go`
- `provider/internal/core/operation_run_bycode.go`
- `provider/internal/core/instance_params.go`
- `provider/internal/core/refsvc_resolve.go`
- `provider/internal/resources_core/resource_diagnostics.go`
- `provider/internal/resources_core/crud.go`
### Группа 4. Генератор (как рождается «универсальная» часть)
- `TOOLS/yaml-generator/main.go`
- `TOOLS/resource-generator/internal/loader/loader.go`
### Группа 5. Факты API (спеки операций/параметров)
- `generated/dev/resources_yaml/19_vc_org.yaml`
- `generated/dev/resources_yaml/22_vc_nsxt.yaml`
### Группа 6. Применение модификаторов (композиция цепочки)
- `DEV_STAND/FullPipe/modifiers.tf`
### Группа 7. Только если без них нельзя ответить (иначе не открывать)
- `provider/internal/resources_core/state_refresh.go`
- `provider/internal/resources_core/params_compare.go`
- `provider/internal/resources_core/helpers.go`
- `TOOLS/resource-generator/main.go`
- `TOOLS/resource-generator/internal/templates/instance.go`
## Что нужно на выходе
Структурированный отчёт, разделы строго в этом порядке:
1. **Устройство архитектуры** — слои и поток данных, 5–10 строк.
2. **Спека ↔ код** — список расхождений `ARCHITECTURE.md` с реализацией (`файл:строка`).
3. **Дефекты и риски модификаторов** — по убыванию критичности. По каждому:
суть → место (`файл:строка`) → последствие → предлагаемое направление (одна строка).
4. **Открытые вопросы** — списком, если есть.
## Формат ответа
- Максимально сжато: тезисы, без вступлений, воды и «лирики».
- Каждое утверждение проверяемо: ссылка `файл:строка`.
- Код — только короткие фрагменты, и лишь если без них тезис не понятен.
- Никаких «а ещё могу», никаких предложений расширить работу.
## Правило «стоп»
Если задание неоднозначно или данных не хватает — **остановиться и задать один короткий
вопрос**. Не достраивать смысл и не действовать по догадке.
```
### Итоговое сообщение агента пользователю (дословно)
```
Готово.
`NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers.md` (коммит `752244f`).
Что внутри:
- **Задача** — анализ архитектуры провайдера + слоя модификаторов (`Create/Read/Update/Delete`, идемпотентность, drift, спека↔код, место в универсальном ядре, границы/откат, топ-рисков с `файл:строка`).
- **Границы (жёстко)** — запрет на `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `docs/**`, стенды, git-историю; нужен файл вне списка → вопрос мне, не чтение.
- **Список файлов** — 22 шт., исчерпывающий, 7 групп: спека (`TOOLS/ARCHITECTURE.md`) → модификаторы + регистрация → рантайм-ядро → генератор → YAML-спеки 19/22 → пример (`DEV_STAND/FullPipe/modifiers.tf`) → «только при необходимости».
- **Формат ответа** — сжато, тезисы, каждое утверждение с `файл:строка`, без «лирики».
- **Режим** — диалог, но без злоупотребления; при неясности один короткий вопрос и стоп.
Все 22 пути проверены — существуют.
```
---
## Ход 5 — Ответ Opus 4.8 (дословно, как получен от пользователя)
Ниже — полный сырой лог сессии Opus (включая служебные строки о чтениях и неудачных поисках),
затем его отчёт.
```
Read
Прочитаю файлы строго по разрешённому списку. Начну с групп 1–3.
Read
Read
Прочитаю группу 2 (модификаторы) и группу 3 (ядро).
Read
Read
Прочитаю группу 3 (ядро) и тест.
Read
Read
Read
Read
Read
Read
Прочитаю `client.go` (ядро) и группу 4 (генератор).
Read
Прочитаю группу 4 (генератор), группу 6 (modifiers.tf) и начну группу 5.
Read
Read
Read
Прочитаю группу 5 (API YAML). Сначала проверю размер.
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
Searched for files matching `**/generated/dev/resources_yaml/*vc_org*.yaml`, no matches
Searched for files matching `**/generated/dev/resources_yaml/*nsxt*.yaml`, no matches
Searched for files matching `**/generated/dev/**/19_*.yaml`, no matches
Read
Searched for files matching `**/generated/dev/resources_yaml/*.yaml`, no matches
Read
Файлы существуют; grep не сработал по паттерну. Прочитаю их напрямую.
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
Read
Read
Проанализировал все файлы из разрешённого списка (группы 1–6; группа 7 не понадобилась). Отчёт ниже.
```
### Отчёт Opus (дословно)
```markdown
## 1. Устройство архитектуры
Поток данных: **API → YAML → Go-код → API**.
- Генератор YAML (`main.go`) делает **чистую выгрузку** операций сервиса из API (`create/delete/modify/suspend/...`) без доменных знаний — один файл на сервис (`19_vc_org.yaml`, `22_vc_nsxt.yaml`).
- Генератор кода (`loader.go`) читает YAML, классифицирует операции на `instance/subresource/action/modifier`, строит `GenResource/...` и рендерит универсальные ресурсы.
- Ядро (`core`) — универсальный HTTP-клиент: запуск операций, ожидание, резолв ref-параметров, досылка дефолтов.
- **Модификаторы** — это НЕ генерируемый слой: два ручных Go-ресурса (`org_ip_allocation_resource.go`, `nsxt_snat_resource.go`), зарегистрированных вручную в `provider.go:199-204`. Каждый дёргает `modify` родителя через `RunInstanceOperationUniversalByCode`.
## 2. Спека ↔ код (расхождения)
**S1. Реестр `serviceSpecificModifiers` в спеке указан не там, где он есть (его нет).**
`ARCHITECTURE.md` (таблица «Exception Registry») утверждает, что `serviceSpecificModifiers` живёт в `main.go`. Фактически этот реестр **удалён** (рефакторинг 2026-09-23) — прямо задокументировано в `main.go:70-90`. Названного реестра нет; модификаторы стали ручными ресурсами. Таблица-спека устарела.
**S2. «Service-specific Go code is fully generated… No manual edits» / «Core is universal» — нарушено.**
`ARCHITECTURE.md` (Core Principles 2, 3; «Provider Model»). Но `org_ip_allocation_resource.go` и `nsxt_snat_resource.go` — ручной сервис-специфичный Go с зашитыми `service_id`, именами параметров и канон-значениями, лежащий в слое `resources_core`. Это доменная логика внутри «ядра».
**S3. Заявленные «ровно ДВА реестра исключений» — по факту есть третье место.**
`ARCHITECTURE.md` («The ONLY allowed deviations… MUST live in exactly two named registries»). Третье место доменных данных/логики — `provider.go:199-204` + два файла модификаторов. Оно не покрыто правилом реестра и не отражается в его диффе.
**S4. «modify всегда через WithDefaults» — модификаторы идут через ByCode.**
`ARCHITECTURE.md` («modify всегда через WithDefaults / `RunInstanceOperationUniversalWithDefaults`»). Модификаторы намеренно используют `RunInstanceOperationUniversalByCode` (`operation_run_bycode.go:10-13`) с причиной в комментарии `nsxt_snat_resource.go:240-250`. Досылка дефолтов там своя (live → paramValue формы → default, `operation_run_bycode.go:105-150`). Буквальное утверждение спеки не выполняется.
**S5. Полноценный генерируемый слой модификаторов существует, но не задействован.**
`loader.go:55-90` полностью поддерживает `kind: modifier` с `delete_strategy` (`noop_warn/inverse/error`), `idempotency` (`none/check_before_run`), `delete_params`, валидацией (validateModifierOperation). Ядро под это имеет `RunInstanceOperationUniversalByIdempotent` (`operation_run_bycode.go:15-19`) и `RunOperationByCodeIdempotent` (`crud.go`). Но оба реальных модификатора — ручные и это всё **не используют**, переизобретая delete-стратегию вручную (`keep_on_destroy` + inverse). Базовые YAML (19, 22) `kind: modifier` не содержат — оверлей `modifiers.yaml`, упомянутый в `main.go:84-90`, в разрешённом списке отсутствует и в базовых спеках не проявлен.
## 3. Дефекты и риски модификаторов (по убыванию критичности)
**R1. Двойное владение одним и тем же параметром API.**
Суть: `vIPConfigure` (id 662) есть в `modify` генерируемого `nubes_vc_org` (`19_vc_org.yaml`, op modify), а `ipSpaceName` (id 372) — в `modify` генерируемого `nubes_vc_nsxt` (`22_vc_nsxt.yaml`). Те же поля пишет и модификатор.
Место: `org_ip_allocation_resource.go:316-340` / `nsxt_snat_resource.go:240-253`.
Последствие: если пользователь заводит и инстанс-ресурс, и модификатор — «война дрейфов»: два ресурса по очереди перезаписывают поле каждым apply.
Направление: явно исключать пересекающиеся коды из схемы генерируемого ресурса, если поле отдано модификатору (или запретить одновременное использование).
**R2. Зашитые сервис-специфичные данные обходят страж `check_hardcoded_service_ids.sh`.**
Суть: id 19/22, имена `vIPConfigure`/`ipSpaceName`, значение `"no-needed"` зашиты как литералы-аргументы, а не как `svc.ID == N`.
Место: `org_ip_allocation_resource.go:296-314` (`ResolveRefSvcParamValue(ctx, 19, …)`), `nsxt_snat_resource.go:43`.
Последствие: правило «никаких hardcoded service id вне реестров» формально соблюдено, фактически — нет; страж это не ловит.
Направление: вынести id/коды/каноны в один явный реестр-данные, покрытый чекером, либо расширить паттерн чекера.
**R3. Порядок «edge → аллокация» не гарантируется провайдером.**
Суть: платформа требует существующий vDC+Edge до `modify` орги, иначе «Can't cast Complex Object Type Struct to String».
Место: описано в `modifiers.tf:6-16`; в коде порядок не выражен — держится только на пользовательском `depends_on`.
Последствие: забытый `depends_on` → непонятная ошибка платформы на apply.
Направление: либо документировать как жёсткое требование в схеме/описании ресурса, либо проверять готовность edge в `Create` до modify.
**R4. Нельзя снять аллокацию через атрибут — только `destroy`.**
Суть: пустой массив запрещён (`org_ip_allocation_resource.go:328-331` `len==0 → error`), а Delete шлёт `count=0`, но `[]` не отправляется (`org_ip_allocation_resource.go:246-266`).
Последствие: «выключено» выражается двумя разными способами (count=0 при destroy vs невозможность `[]` при update) — асимметрия семантики.
Направление: определить единый канон «ноль аллокаций» и разрешить его через атрибут, либо явно задокументировать ограничение как намеренное.
**R5. Модификаторы не идемпотентны на уровне API (modify выполняется всегда).**
Суть: `ByCode` без pre-check — `Create`/`Update` всегда POST-ят modify, даже если live уже совпадает. Причина осознанная (комментарий applyAllocation).
Последствие: лишние операции modify при повторных apply/после ручных правок state; на больших инстансах — время и нагрузка. Корректности не нарушает (дрейф гасится в `Read`).
Направление: если появится надёжное сравнение desired↔live (не с paramValue формы) — переключить на idempotent-путь, уже имеющийся в ядре.
**R6. Импорт оставляет Required-атрибуты неинициализированными.**
Суть: `ImportState` ставит только `id` и `organization`/`nsxt_uid` (`org_ip_allocation_resource.go:288-292`, `nsxt_snat_resource.go:222-226`). `vip_configure`/`ip_space_name` (Required) наполняются лишь в `Read`, и то по условию (nsxt — только если live непустой `nsxt_snat_resource.go:160-166`).
Последствие: при импорте эджа с невыставленным SNAT `ip_space_name` останется null → требуется совпадение с конфигом на следующем плане; для пользователя неочевидно.
Направление: в `ImportState`/`Read` заполнять атрибут каноном (`no-needed`) при отсутствии значения.
## 4. Открытые вопросы
1. Оверлей `modifiers.yaml` и генерируемый слой `kind: modifier` (см. R5/S5) — в разрешённый список не входил. Нужно ли оценивать его как «мёртвый/параллельный» код к удалению, или он планируется как целевая замена ручных модификаторов? (файл вне списка — не читал).
```
---
## Ход 6 — Указание пользователя (дословно)
```
сначала документируй ВЕСЬ диалог, без сокращений
```
---
## Открытый вопрос Opus (ожидает ответа пользователя)
1. Оверлей `modifiers.yaml` и генерируемый слой `kind: modifier` — «мёртвый/параллельный» код
к удалению, или планируемая целевая замена ручных модификаторов?
---
## Ход 7 — Запрос пользователя (дословно)
```
твоё критическое мнение ?
```
### Критическое мнение агента о отчёте Opus (дословно)
```
**Сильное**
- **S1** — верно. `ARCHITECTURE.md:225` ссылается на реестр `serviceSpecificModifiers`, а в `TOOLS/yaml-generator/main.go` (~стр. 77–90) написано, что он удалён при рефакторинге. Спека устарела — цена находки низкая, но факт точный.
- **S5** — верно и важно. `loader.go` умеет `kind: modifier` (+ `delete_strategy`, `idempotency`) и в ядре есть idempotent-путь, но реальные модификаторы ручные. Это не «мёртвый код», а **незавершённая миграция**.
- **R1** — самое ценное. `nubes_vc_nsxt` в `Update` шлёт `ip_space_name` (372), и тот же параметр пишет `nubes_vc_nsxt_snat` → два ресурса перезаписывают одно поле. Аналогично `nubes_vc_org` / `v_ip_configure`.
- R4, R6 — по делу, но второго порядка.
**Слабое / спорное**
- **S2, S4 — натянуто.** Правила спеки («no manual edits», «core is universal») писаны про **сгенерированный** код; ручной оверлей в них вообще не описан. Это пробел спеки, а не нарушение «по букве». S4 ещё и относится к `Update` инстанс-ресурсов, к модификатору прямого отношения не имеет.
- **R2 — нарушение собственного правила.** Opus утверждает, что `check_hardcoded_service_ids.sh` «это не ловит», **не читая сам скрипт** (он вне списка). Надо было оформить вопросом, а не утверждением.
- **R5 — завышен по приоритету**: сам же пишет «корректности не нарушает».
- **Номера строк не сверены.** У меня из чтения `provider.go` функция `Resources` начинается со строки 182, и регистрация модификаторов идёт сразу за ней, а не 199–204. Одно из двух неверно → «проверяемость» отчёта под вопросом (а она была главным требованием).
**Упущено**
- Файла `modifiers.yaml` в репозитории **нет вообще** — только упоминания-комментарии. Значит это не «параллельный слой», а **задокументированное, но не созданное** наложение; его вопрос №1 надо переформулировать.
- Не названа корневая причина: генератор умеет модификаторы, но доменное знание (какая операция, имя, inverse) некуда положить — оверлей не сделан, поэтому написали руками.
- Не отмечена рассинхронизация семантики: у генерируемых ресурсов `suspend_on_destroy`, у модификаторов `keep_on_destroy` — два имени для одного смысла.
- То, что для Terraform опаснее всего — устойчивость `Read` (вечный diff), — разобрано тоньше всего, хотя именно там был реальный баг с порядком ключей JSON.
**Итог:** ~60 % полезного (S1, S5, R1, R4 + вопрос про оверлей), но с натяжками в S2/S4/R2 и без разбора `Read`. Главный дефект — поверхностный lifecycle-анализ.
```
---
## Ход 8 — Указание пользователя (дословно)
```
так сделай ему замечание !!! пусть глубже копнёт ЕСЛИ ЭТО НАДО
```
### Замечания агента (раунд 2) — текст, отправляемый Opus
Сохранён отдельным файлом: `NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers_round2.md`.
Содержимое (дословно):
```markdown
# Промпт для Opus 4.8 — РАУНД 2: замечания к отчёту (2026-09-30)
> Продолжение диалога. Раунд 1 — `prompt_for_opus_provider_architecture_modifiers.md`.
> Формат ответа тот же: сжато, тезисы, `файл:строка`, без догадок. Границы доступа — как в раунде 1
> (плюс список из §4 ниже). `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, git-история — по-прежнему ЗАПРЕЩЕНЫ.
---
## 1. Зачтено (переделывать НЕ надо)
`S1`, `S5`, `R1`, `R4` — приняты. Не повторяй их в ответе.
## 2. Замечания — обязательны к отработке
**M1. Номера строк не сходятся.**
Ты дал `provider.go:199-204` для регистрации модификаторов. По моему чтению файла (начиная со
строки 180) функция `Resources()` находится примерно на строке 182, и регистрация идёт сразу за ней —
твои 199–204 не сходятся. Требование задания — «каждое утверждение проверяемо».
Действие: перепроверь **каждую** ссылку `файл:строка` в отчёте и дай точные номера; где не сверял —
пометь «не сверено». Без этого отчёт не принимается.
**M2. `R2` — нарушено правило «без догадок».**
Ты утверждаешь, что `check_hardcoded_service_ids.sh` «это не ловит», но этот скрипт **не читал**
(его не было в разрешённом списке). Это догадка, а не факт.
Действие: скрипт теперь разрешён (см. §4). Либо приведи факт из его кода, либо переформулируй в вопрос.
**M3. `S2`/`S4` — проверь основание, иначе они натянуты.**
Правила спеки («No manual edits to **generated** Go code», «Service-specific Go code is fully
**generated** from YAML») писаны про генерируемый код. Ресурсы в `resources_core` — ручные, не
генерируемые. Плюс `S4` («modify всегда через WithDefaults») относится к `Update` инстанс-ресурсов,
а не к отдельному ресурсу-модификатору.
Действие: для каждого из S2/S4 дай **текстуальную опору из спеки** (`TOOLS/ARCHITECTURE.md:строка`)
и переформулируй: это **пробел спеки** (нет категории для ручных оверлеев) или **нарушение**? Если
опоры нет — пункт снять.
**M4. `R5` — обоснуй приоритет или понизь.**
Ты сам пишешь «корректности не нарушает», но ставишь R5 выше R6.
Действие: назови шкалу ранжирования (например: вероятность × последствие × обнаружимость) и
пересчитай порядок; либо понизь R5.
**M5. Главный пробел: устойчивость `Read` и вечный diff.**
Для Terraform это опаснее всего, а разобрано тоньше всего (только R6/импорт).
Действие: разбери построчно, как `Read` модификатора формирует `vip_configure` / `ip_space_name`
из live-состояния и совпадёт ли это с тем, что вернёт `jsonencode` из конфига:
- порядок ключей JSON;
- тип `count` (строка vs число);
- снятие `null` и пустых значений;
- что видит `plan` после `Read` для `keep_on_destroy`.
Отдельно: может ли `Update` инстанс-ресурса (`nubes_vc_org` / `nubes_vc_nsxt`) вклиниться между
apply-ами модификатора и вернуть поле назад (пересечение — это R1, но нужен сценарий во времени).
Если считаешь, что глубокий разбор здесь не нужен — обоснуй одной строкой, почему.
## 3. Что упущено — доработать
**U1. Файла `modifiers.yaml` в репозитории НЕТ.**
Есть только упоминания-комментарии (в `TOOLS/yaml-generator/main.go` и док-заметках). Это не
«параллельный слой», а **задокументированное, но не созданное** наложение.
Действие: переформулируй свой открытый вопрос №1 с учётом этого факта.
**U2. Рассинхронизация словаря жизненного цикла.**
У генерируемых ресурсов — `suspend_on_destroy`, у модификаторов — `keep_on_destroy`. Два имени
для одного смысла, живут в разных ветках кода.
Действие: оцени как архитектурный дефект (единый словарь/контракт жизненного цикла), с `файл:строка`.
**U3. Корневая причина «ручных» модификаторов.**
Генератор умеет `kind: modifier`, но доменное знание (какая операция, имя, inverse) некуда положить —
оверлей не создан.
Действие: сформулируй вывод по вариантам (оверлей-данные vs ручной код vs иное) — одной строкой на вариант.
## 4. Дополнительно РАЗРЕШЕНО читать (только это, сверх раунда 1)
- `TOOLS/scripts/check_hardcoded_service_ids.sh`
- `TOOLS/resource-generator/main.go`
- `TOOLS/resource-generator/internal/templates/instance.go`
- `TOOLS/resource-generator/internal/templates/modifier.go`
- `provider/internal/resources_core/state_refresh.go`
- `provider/internal/resources_core/helpers.go`
- `provider/internal/resources_core/params_compare.go`
Всё остальное — по-прежнему нельзя. Нужен файл вне списка → вопрос мне, не чтение.
## 5. Формат ответа
- **Только дельта**: что исправляешь, что снимаешь, чем обосновываешь; новые находки по M5/U2.
- Полный отчёт заново не переписывать.
- Как в раунде 1: тезисы, `файл:строка`, без «лирики» и без «а ещё могу».
## 6. Разрешение копать глубже
Если для честного ответа нужно копнуть глубже (в т.ч. по файлам вне §4) — **скажи прямо**, каких
файлов не хватает, и задай вопрос. Если глубина не нужна — так и напиши по конкретному пункту.
```
---
## Ход 9 — Ответ Opus на раунд 2 (дословно, как получен от пользователя)
### Сырой лог сессии
```
Read
Раунд 2. Перепроверю номера строк и прочту новые разрешённые файлы.
Read , lines 193 to 195
Read
Read , lines 178 to 195
Searched for regex `ResolveRefSvcParamValue\(ctx, 19|noNeededIpSpace|func \(r \*|len\(items\) == 0|RunInstanceOperationUniversalByCode|liveItems, parseErr|if raw, ok := live\["ipSpaceName"\]|func parseVipConfigure|func formatVipConfigure|func vipAllocationsEqual` (`**/provider/internal/resources_core/org_ip_allocation_resource.go`), 21 results
Searched for regex `noNeededIpSpace|func \(r \*NsxtSnat|if raw, ok := live\["ipSpaceName"\]|func \(r \*NsxtSnatResource\) setSnat|RunInstanceOperationUniversalByCode|ImportState` (`**/provider/internal/resources_core/nsxt_snat_resource.go`), 18 results
Прочитаю новые разрешённые файлы: шаблоны генератора, helpers, params_compare, state_refresh.
Read
Read
Прочитаю helpers.go, state_refresh.go, params_compare.go для проверки поведения `ParamFormat`/refresh.
Read
Проверю точные номера строк в `ARCHITECTURE.md` для опоры S2/S4.
Searched for regex `must not contain service-specific|fully generated from YAML|No manual edits|no service-specific logic inside the core|modify всегда через WithDefaults|serviceSpecificModifiers|exactly two named registries` (`**/TOOLS/ARCHITECTURE.md`), 7 results
Searched for regex `for _, param := range cfsParams|func \(c \*UniversalClient\) RunInstanceOperationUniversalByCode|RunInstanceOperationUniversalByIdempotent|val, hasLive := lookupLiveParam` (`**/provider/internal/core/operation_run_bycode.go`), 5 results
Read , lines 174 to 193
Дельта по раунду 2.
```
### Дельта (дословно)
```markdown
## M1 — перепроверенные `файл:строка`
| Ссылка раунда 1 | Было | Факт (сверено) |
|---|---|---|
| Регистрация модификаторов | `provider.go:199-204` | `provider.go:182` — `func Resources()`; регистрация — `provider.go:186` и `provider.go:187` |
| Удалённый реестр в генераторе | `yaml-generator/main.go:70-90` | `main.go:80-90` (комментарий про снятый `serviceSpecificModifiers`) |
| hardcoded id 19 | `~300` | `org_ip_allocation_resource.go:308` |
| `noNeededIpSpace` | `43` | const — `nsxt_snat_resource.go:44`; `setSnat` — `nsxt_snat_resource.go:234`; inverse-modify — `nsxt_snat_resource.go:199-200` |
| Досылка дефолтов ByCode | `105-150` | `operation_run_bycode.go:112-150`; `ByCode` — `operation_run_bycode.go:11`, `ByIdempotent` — `operation_run_bycode.go:18` |
| `len(items)==0` → error | `328-331` | `org_ip_allocation_resource.go:333` |
| GenModifier-конвейер | `55-90` | `loader.go:57-86` (`op.Kind=="modifier"`) |
Прочее сверено: org_ip `ImportState` `org_ip_allocation_resource.go:290`; nsxt `ImportState` `nsxt_snat_resource.go:224`; nsxt `Read` overwrite `nsxt_snat_resource.go:152`; org_ip `Read` semantic-compare `org_ip_allocation_resource.go:177-188`.
## M2 — R2 переформулирован как ФАКТ (скрипт прочитан)
`check_hardcoded_service_ids.sh:15`: `grep -rnE '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' "$ROOT_DIR/TOOLS"`.
Два проверяемых факта:
1. **Область — только `TOOLS`.** Дерево `provider` не сканируется вовсе → ручные модификаторы в `resources_core` вне охвата стража по определению.
2. **Паттерн ловит только `.ServiceID==N` / `.ID==N`.** Литерал-аргумент `org_ip_allocation_resource.go:308` (`ResolveRefSvcParamValue(ctx, 19, …)`) под паттерн не подпадает даже теоретически.
Итог R2: hardcoded 19/22/`"no-needed"` не покрыты стражем по **двум** причинам (область + паттерн). Догадки убраны.
## M3 — S2/S4 пересмотрены
**S4 — СНИМАЮ.** Опора `ARCHITECTURE.md:134` находится в разделе «Generated Code Resilience» и относится к `Update` генерируемых инстанс-ресурсов, а не к ручному модификатору. К модификаторам правило неприменимо → расхождения нет.
**S2 — переклассифицирую в ПРОБЕЛ СПЕКИ (не «нарушение No manual edits»).**
Опоры: `ARCHITECTURE.md:13` и `ARCHITECTURE.md:213` говорят про *generated* Go — к ручным файлам не относятся (замечание верное). Но `ARCHITECTURE.md:12` («core … must not contain service-specific logic») и `ARCHITECTURE.md:110` («no service-specific logic inside the core») сформулированы про «core» без оговорок. Ручные модификаторы с зашитыми id 19/22 лежат в `resources_core` (`org_ip_allocation_resource.go:308`, `nsxt_snat_resource.go:44`). Спека **не содержит категории «ручной сервисный оверлей»** и не говорит, является ли `resources_core` частью «core». Поэтому S2 — пробел контракта (нет разрешённого места для такого кода), а по букве принципа 2 — пограничное противоречие. Не «нарушение No manual edits».
## M4 — шкала ранжирования и пересчёт
Шкала: **severity = вероятность × тяжесть_последствия × (1/обнаружимость)**. «Тихие» дефекты (низкая обнаружимость) поднимаются; то, что сразу видно в плане/диффе — опускается.
- **R2** — вер. высокая (любое добавление сервис-логики), тяжесть средняя (эрозия инварианта «ядро универсально»), обнаружимость низкая (страж молчит) → **верх**.
- **R3** — вер. средняя (забытый `depends_on`), тяжесть средняя (непонятная ошибка платформы), обнаружимость средняя → **середина**.
- **R6** — вер. средняя (импорт редок, но реален), тяжесть низкая, обнаружимость средняя → **ниже R3**.
- **R5** — «корректности не нарушает» (моё же слово), тяжесть минимальная, обнаружимость высокая → **низ**.
Новый порядок: **R2 > R3 > R6 > R5**. R5 понижен ниже R6 (замечание принято).
## M5 — устойчивость `Read` и вечный diff (главный разбор)
**org_ip, путь значения `vip_configure`:**
- Конфиг: `jsonencode([{name,count}])` → ключи по алфавиту (`count`,`name`); `count` — тип из `var.ip_count` (в `modifiers.tf:24` не квотирован → зависит от типа переменной).
- Канон провайдера `org_ip_allocation_resource.go:385`: `name` первым, `count` — всегда строка.
- Эти две формы **байт-различны** (порядок ключей; возможно число vs строка).
- Защита от вечного diff — `org_ip_allocation_resource.go:177-188`: сравнение **смысловое** (`org_ip_allocation_resource.go:398`, игнорирует порядок и формат), и при совпадении state **не перезаписывается** → в state остаётся байт-в-байт конфиг-форма → `plan` чист. `count`-число нормализуется в строку в `org_ip_allocation_resource.go:351` до сравнения, поэтому «3» (число) и «"3"» считаются равными. `null`/пустые/`[{}]` отбрасываются там же → не порождают фантомную аллокацию.
- Перезапись (реальный дрейф) даёт канон-форму (name-first) — она никогда не совпадёт байтово с `jsonencode`, но diff тогда **легитимен**; ближайший `Update` ставит `state=plan` (`org_ip_allocation_resource.go:119`) и вечного diff не создаёт.
**nsxt_snat, путь `ip_space_name`:** скаляр-строка. `nsxt_snat_resource.go:152` перезаписывает state только если live-значение непустое; иначе оставляет план (защита Required-атрибута от null). Форматных расхождений нет (простое имя) → вечного diff нет.
**`keep_on_destroy` в плане:** Optional+Computed, `Default=false` (`org_ip_allocation_resource.go:84`, `nsxt_snat_resource.go:78`). `Read` читает его из state в начале и не трогает (наружу его в API нет) → после первого apply стабилен, `plan` чист. В `modifiers.tf:30` задан `true` явно — diff отсутствует.
**Вывод M5-основной:** сами модификаторы вечного diff **не дают** — смысловое сравнение в `Read` его гасит.
**Временной сценарий R1 (инстанс-ресурс возвращает поле назад):**
Шаблон instance.go Update строит `params` из **всех** `ModifyParams` безусловно (не пропускает null для не-nested), и вызывает `UpdateResourceWithTimeout` → `RunInstanceOperationUniversalWithDefaults`. Гейт — `hasServiceParamChanges` (тот же шаблон): modify запускается, если изменился **любой** modify-параметр.
- `vc_org`: modify содержит **только** `vIPConfigure` (`19_vc_org.yaml`, op modify) → `nubes_vc_org` тронет поле, лишь если пользователь сам задал `vIPConfigure` на инстанс-ресурсе. Риск ниже.
- `vc_nsxt`: modify содержит `needEnableAVI`, `virtualServicesCount`, `ipSpaceName`, `qosProfile`, `routedNetConfiguration` (`22_vc_nsxt.yaml`). Сценарий: (t1) модификатор включил SNAT → (t2) пользователь на `nubes_vc_nsxt` меняет `needEnableAVI` → `hasServiceParamChanges=true` → `params[372]=ParamFormat(plan.IpSpaceName)` (запись присутствует всегда) → (t3) следующий `Read` модификатора видит дрейф и на очередном apply восстанавливает. Две сущности «пинают» поле по очереди.
**Не сверено** (нужны файлы вне §4): пошлёт ли `RunInstanceOperationUniversalWithDefaults` пустой `ipSpaceName` как `""` (затрёт SNAT) или дособерёт из live (как ByCode). Это решает, «затирание» или «no-op». Файлы: `core/operation_*` с реализацией `WithDefaults` и генератор-хелпер `ParamFormat` (funcs.go) для поведения null→"". См. M6.
## U1 — открытый вопрос №1 переформулирован
`modifiers.yaml` в репозитории **нет** — только упоминания-комментарии (`main.go:84-90`). Генерируемый слой модификаторов при этом **реален и готов**: шаблон `modifier.go` (полный CRUD + `reconcile` + `delete_strategy` + `idempotency`) и конвейер `loader.go:57-86`. То есть это **задокументированное-но-несозданное наложение**: механизм есть, данных для него нет. Вопрос: создавать `modifiers.yaml`-оверлей (данные) и перевести org_ip/nsxt_snat на генерацию — или узаконить ручные ресурсы отдельной категорией в спеке?
## U2 — рассинхронизация словаря жизненного цикла (архитектурный дефект)
Три несогласованных словаря «что делать при destroy», в разных ветках кода:
1. Генерируемый инстанс: `suspend_on_destroy` + `keep_on_destroy` (runtime-булевы, приоритет keep) — instance.go schema и `Delete` (ветки `suspend`/`state_only`).
2. Генерируемый модификатор: `delete_strategy` = `noop_warn`/`inverse`/`error` — **compile-time из YAML**, без `keep_on_destroy` — modifier.go Delete.
3. Ручной модификатор: снова `keep_on_destroy` (runtime) + ручной inverse — `org_ip_allocation_resource.go:84`, `org_ip_allocation_resource.go:195`; `nsxt_snat_resource.go:78`.
Дефект: один смысл («не трогать эффект при destroy / обратный откат») выражен тремя разными контрактами (runtime-флаг vs compile-time-стратегия), причём генерируемый и ручной модификаторы, решая одну задачу, используют **противоположные** механизмы. Нужен единый словарь жизненного цикла для всех трёх видов ресурсов.
## U3 — корневая причина «ручных» модификаторов (по вариантам, одной строкой)
- **Оверлей-данные (`modifiers.yaml` + второй проход):** доменное знание (сервис, операция, имя, inverse, idempotency) — декларативно; org_ip/nsxt_snat становятся генерируемыми; убирает S2/U2/R2 разом; цена — доделать загрузчик оверлея и миграцию.
- **Ручной Go-код (статус-кво):** максимум гибкости под нестандартную семантику (replace-массив, `no-needed`); цена — сервис-логика в `resources_core`, обход стража, тройной словарь.
- **Иное (гибрид):** генерировать каркас из оверлея, а нетиповые куски (канон vIPConfigure) выносить в маленькие data-хуки; цена — усложнение шаблона ради 1-2 сервисов.
## M6 — где нужна глубина (прошу доступ)
Для однозначного вердикта по временному R1 (затирание vs досбор) не хватает **двух** файлов вне §4:
1. Реализация `RunInstanceOperationUniversalWithDefaults` (ядро, `core/operation_run*.go` / `operation_cfs.go`) — шлёт ли пустой `ipSpaceName` как `""`.
2. Генератор-хелпер `ParamFormat` (`TOOLS/resource-generator/internal/.../funcs.go`) — во что разворачивается null-строка в `params`.
Разрешить чтение этих двух файлов? Без них пункт помечен «не сверено».
```
---
## Ход 10 — Инструкция и пояснение пользователя (дословно)
```
#attachment:Pasted text #1
докуменитируй сразу
пояснияю - модификаторы - модет и слодная, новая, но НЕБОЛЬШАЯ часть всего нашего сервиса
не нужно делать на них чрезмерный упор
главное - САМ провайдер, его устойчивость и правильность и тд
```
Разбор указания (для дальнейших раундов):
- **Документировать немедленно** — исполнено этим разделом.
- **Приоритет — САМ провайдер**: его устойчивость и корректность. Модификаторы — новая и, возможно,
сложная, но **НЕБОЛЬШАЯ** часть сервиса; чрезмерный упор на них делать не нужно.
- Следствие: темы `M5`/`M6`/`U2` (глубина по модификаторам) — второстепенны по отношению к вопросам
устойчивости/корректности ядра, генерации и всего провайдера в целом.
---
## Ход 10a — Запрос пользователя (дословно)
```
твоё мнение ?
```
Мнение агента по раунду 2 приведено ниже, внутри Хода 11 (раздел «Критическое мнение агента
о раунде 2»).
---
## Ход 11 — Запрос пользователя (дословно)
```
так дополни задание ему, пусть ещё анализирует НО не нало много - токены могути закончиться
```
### Критическое мнение агента о раунде 2 (дословно, отправлено пользователю)
```
**Что стало лучше**
- **M1** — Opus честно перепроверил и исправился: `provider.go:182/186/187`. Совпадает с моим чтением → доверие к остальным номерам выросло.
- **M2** — догадка устранена: приведена конкретная строка скрипта, область (только `TOOLS/`) и паттерн (`.ServiceID==N`/`.ID==N`). Это уже факт, и факт полезный: дерево `provider/` стражем не покрыто **вообще**.
- **M3** — `S4` снят с текстуальной опорой (`ARCHITECTURE.md:134` в разделе про generated-код), `S2` переклассифицирован в пробел спеки с опорой (`:12`, `:110` vs `:13`, `:213`). Честно и корректно.
- **U2** — новая ценная находка, которой у меня не было: **третий** словарь жизненного цикла — `delete_strategy` (compile-time в YAML) против runtime-флагов. Настоящий архитектурный дефект.
- **M4** — шкала задана, `R5` понижен. Замечание принято по существу.
**Что остаётся слабым**
- **M5 — это чтение кода, а не замер.** Вся защита от вечного diff держится на утверждении «в `Read` смысловое сравнение, и при совпадении state не перезаписывается». Это правдоподобно и проверяемо, но **не проверено прогоном** `terraform apply` ×2 / `plan`. По нашему же правилу «рекомендация без замера — догадка». Opus не предложил верификацию.
- **Риск переоценён/недооценён.** Самое опасное (R1: `Update` инстанс-ресурса затирает `ipSpaceName`) осталось **нерешённым** — за M6. При этом Opus сам пишет, что для `vc_nsxt` modify содержит 5 параметров, и запись `params[372]` присутствует **всегда**. Если так — это не «война дрейфов», а **молчаливое затирание SNAT** при любой правке эджа. Это надо было ставить вопросом №1 прошлого раунда, а не откладывать.
- **Ничего про сам провайдер.** Раунд целиком про модификаторы — ровно то, что вы просили не переоценивать. Ядро, генератор, устойчивость, корректность — не тронуто.
**Моё мнение**
Раунд 2 — честный и в плюс (правки по номерам, снятие `S4`, находка `U2`). Но по вашему приоритету он **мимо цели**: модификаторы, а не провайдер; и при этом главный вопрос (затирание `ipSpaceName` при `Update` эджа + отсутствие замера) остался открытым. Логичный следующий ход — развернуть раунд на «провайдер целиком», а `M6` **отложить**, а не разворачивать.
```
---
## Ход 12 — Задание агента (раунд 3) — текст, отправляемый Opus
Сохранён отдельным файлом: `NOTES/20_prompts/prompt_for_opus_provider_core_round3.md`.
Содержимое (дословно):
```markdown
# Промпт для Opus 4.8 — РАУНД 3: сам провайдер (устойчивость и корректность)
> Продолжение диалога. Раунды 1–2 — `HISTORY/OPUS/2026-09-30_provider_architecture_modifiers_dialog.md`.
> Режим и формат ответа — как раньше: сжато, тезисы, `файл:строка`, без догадок.
---
## Вводная (смена приоритета)
Модификаторы (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`) — новая, возможно сложная, но
**НЕБОЛЬШАЯ** часть сервиса. Чрезмерный упор на них не нужен.
**Главное — САМ провайдер: его устойчивость и корректность.**
- `M6` (доступ к двум файлам для доразбора R1) — **снять**; углубление по модификаторам больше не требуется.
- Прошлые находки (`S1`, `S2`, `R2`–`R6`, `U1`–`U3`) **не повторять**.
## Бюджет (жёстко — экономим токены)
- Прочитать **не более 10 файлов** суммарно. Ранее прочитанные повторно не открывать.
- Ответ — **не более 5 находок**, каждая **не более 3 строк**.
- Никаких вступлений, повторения прошлых отчётов, «а ещё могу».
## Что анализировать (провайдер целиком)
1. **Жизненный цикл инстанс-ресурса:** `create` / adopt / `suspend` / resume / `modify` / redeploy /
`delete` и повторный `apply` — где теряется корректность состояния.
2. **Досылка и нормализация параметров** (`WithDefaults`, zero-value fallback, дефолты `map-fixed`,
регистр UUID): где риск затереть значение или получить ложный diff.
3. **`Read` / refresh:** устойчив ли state у генерируемых ресурсов; где возможен вечный diff.
4. **Устойчивость ядра:** ретраи и ошибки, `LockInstance`, идемпотентность, поведение при дубликатах
и параллельных `apply`.
5. **Генератор → код:** какие классы дефектов порождает шаблон.
## Границы доступа
Разрешено читать (только это):
- `provider/internal/core/**`
- `provider/internal/resources_core/**`
- `provider/internal/provider/provider.go`
- `TOOLS/resource-generator/internal/templates/**`
- `TOOLS/resource-generator/internal/params/params.go`
- `TOOLS/resource-generator/internal/helpers/helpers.go`
- `TOOLS/resource-generator/internal/loader/loader.go`
- `TOOLS/resource-generator/internal/writers/writers.go`
Запрещено: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, `DEV_STAND/**`, `TEST_STAND/**`,
`PROD_STAND/**`, git-история. Нужен файл вне списка → вопрос мне, не чтение.
## Формат ответа
- **ТОП-5 находок** по убыванию критичности: суть → место (`файл:строка`) → последствие (≤ 3 строк).
- Затем **одна строка**: что из этого проверяемо только замером (прогон `apply`/`plan`), а не чтением.
- Находок меньше пяти — столько, сколько есть. Водой не добивать.
## Стоп-правило
Не хватает файла или данных — один короткий вопрос. Не догадываться.
```
---
## Ход 12a — Сообщение пользователя и вопрос агента
Пользователь прислал **только путь к файлу**, без какой-либо инструкции:
```
/home/naeel/TF/tf_provider/NOTES/20_prompts/prompt_for_opus_provider_core_round3.md
```
Агент, не имея однозначной команды, задал уточняющий вопрос с вариантами (проверить пути из
списка доступа / поправить текст задания / пересмотреть состав и границы / ничего не делать).
**Пользователь вопрос отклонил (cancelled)** — никаких изменений не вносилось, уточнение осталось
без ответа.
---
## Ход 13 — Ответ Opus на раунд 3 (дословно, как получен от пользователя)
### Сырой лог сессии
```
Read
Ограничусь бюджетом. Посмотрю состав `core/` и выберу ключевые непрочитанные файлы.
Read
Read
Read
Прочитаю ещё 4 ключевых файла ядра: ожидание операций, поиск (дубликаты), нормализацию параметров, cfs.
Read
Read
Прочитаю ещё 2: состояние инстанса (проверка ретрая 401) и нормализацию параметров.
Read
Read
Бюджет: прочитано 6 файлов. ТОП-5 находок по устойчивости/корректности ядра.
```
### Отчёт (дословно)
```markdown
**1. Транзиентный 401 не ретраится нигде; `getInstanceStateWithRetry` — имя-обман (ретрая нет).**
`instance_state.go:39` просто зовёт `doRequest` один раз; `http.go:118` `isRetryable` = {429,502,503,504}, **без 401**.
Прямо противоречит ARCHITECTURE.md:105-108 («GetInstanceState retry 401», «doRequest treats 401 as retryable»). Кратковременный 401 Gateway роняет любой read → срыв plan/apply/поллинга.
**2. Ретраится только GET; любой POST падает намертво на транзиентном 502/503/сети.**
`http.go:88` — retry только при `method=="GET"`.
Сбой на `/instanceOperations`, `/instanceOperationCfsParams` или `run` рвёт операцию; при create остаётся осиротевший инстанс → следующий apply ловит страж дубликатов `instance_find.go:168`.
**3. Modify обрывается уже ПОСЛЕ создания операции, если чтение live-параметров сбойнуло.**
`operation_run.go:150` (и симметрично bycode `operation_run_bycode.go:108`): `instanceLiveParams` err → `return`, но POST `/instanceOperations` уже прошёл.
Любой транзиентный сбой чтения live → повисшая pending-операция, блокирующая инстанс до idle.
**4. Zero-value fallback угадывает тип по ПОДСТРОКЕ имени параметра.**
`params.go:47`: при пустом `dataType` тип берётся из `name/code/label`, содержащих `array/list/map/json`.
Строковый параметр с кодом вроде `whitelist`/`...json...` получит `"[]"`/`"{}"` вместо `""` → неверно-типизированное значение уходит в API на незаданном поле.
**5. Эквивалентность значения в `Read` захардкожена под `jsonEnv`/ref_svc — прочие строки пишутся как вернул API.**
`state_refresh.go:143` спец-ветка только для `jsonEnv`; остальное — `ParseString(value)` «как есть» `state_refresh.go:168`. Регистровая нормализация есть лишь в ref_svc-пути `refsvc_resolve.go:29`.
Для non-ref_svc строкового поля, где API канонизирует формат (регистр UUID и т.п.), plan≠state → вечный diff.
Проверяемо только замером (не чтением): №1–3 (поведение при флапающем Gateway, орфан-операции) — прогоном `apply`; №5 (вечный diff) — прогоном `plan`.
```
---
## Ход 14 — Запрос пользователя (дословно)
```
мнение ? более вопросов в том чате задават не получится - он уже большой и жрёт токены
```
### Мнение агента о раунде 3 (дословно)
```
**Сильное**
- **#1** — самое ценное: `ARCHITECTURE.md:105-108` буквально обещает ретрай 401 для GET, а код, по его словам, 401 не ретраит. Если верно — это не «мелочь», а срыв `plan`/`apply`.
- **#3** — правдоподобно и практично: обрыв modify уже **после** отправленного POST → висящая операция.
- **#5** — бьёт в самое больное место Terraform (вечный diff).
**Где не верю / нужна аккуратность**
- **#1 — слово «нигде» настораживает.** Спека говорит, что ретрай 401 живёт внутри `GetInstanceState`/`GetInstanceStateRaw`, а Opus цитирует `instance_state.go:39` — один вызов `doRequest`. Вполне возможно, что ретрай реализован в другой функции, и абсолютный вывод неверен. Проверять надо именно те функции, а не один call-site.
- **#2 — подано как дефект, а это может быть защита.** Слепой ретрай POST `/instanceOperations` **опаснее** обрыва: рискует создать дубликат операции. Настоящий дефект — не «нет ретрая», а «нет идемпотентности / безопасного продолжения». В нынешней формулировке вывод вводит в заблуждение.
- **#4 — нет оценки частоты.** Срабатывает только при **пустом** `dataType`. Без ответа «как часто он пуст» это теория, а не риск.
- **#5 — возможно, уже покрыто.** По §6.5 `ARCHITECTURE_NEW.md` нормализация UUID при **сравнении** есть в `JSONStringsEquivalent` / `JsonNormalize` / `normalizeCompareValue` / `ParamsMatchForResume`. Прежде чем считать это багом, надо убедиться, что расхождение не гасится там.
**Общее**
- Все пять — чтение кода; ни одна не измерена. Это **гипотезы**, и формально Opus это честно признал.
- Раз чат исчерпан по токенам — дальнейшая проверка возможна только **локально**, своими силами: `grep` по `isRetryable`/`GetInstanceState`, сверка `state_refresh.go` с `normalizeCompareValue`.
```
---
## Статус
- Раунды 1–3: все три отчёта получены. Раунд 3 уложился в бюджет (прочитано 6 файлов, 5 находок).
- **Итог раунда 3 (ядро, устойчивость/корректность):**
1. 401 не ретраится нигде; `isRetryable` без 401 — противоречие `ARCHITECTURE.md:105-108`.
2. Ретрай только для GET; POST обрывается на 502/503/сети → риск осиротевших операций/инстансов.
3. Modify может оборваться **после** создания операции (сбой чтения live) → повисшая pending-операция.
4. Zero-value fallback определяет тип по подстроке имени → возможна неверная типизация незаданного поля.
5. Нормализация значения в `Read` — только `jsonEnv`/ref_svc → вечный diff на прочих канонизируемых строках.
- Находки №1–3 и №5 **не подтверждены замером** (по словам самого Opus) — перенесены как гипотезы.
- **Чат с Opus исчерпан по токенам** (сообщено пользователем 2026-09-30): новые вопросы в него
задавать нельзя; проверка находок возможна только локально.
- **Мнения агента записаны по всем раундам:** раунд 1 — Ход 7; раунд 2 — Ход 11 (и Ход 10a);
раунд 3 — Ход 14.
- Артефакты: `752244f` — промпт раунда 1; `ea75507` — замечания раунда 2;
`e46bc35` — задание раунда 3;
`NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers_round2.md`,
`NOTES/20_prompts/prompt_for_opus_provider_core_round3.md`.
- Следующий шаг (2026-09-30): промпт для **DeepSeek Pro** —
`NOTES/20_prompts/prompt_for_deepseek_pro_remediation_plan.md` (план правок кода/документации/архитектуры
+ план проверки/тестов; гипотезы групп A/B переданы ему на верификацию; исполнять будет Copilot).
- Настоящий документ ведётся по прямому указанию пользователя «документируй ВЕСЬ диалог,
без сокращений».
+2 -2
View File
@@ -45,7 +45,7 @@ Provider naming defaults:
Script: `TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/<стенд>`
Input list of services:
- `TOOLS/config/services_list.txt` (service_id only)
- `TOOLS/config/<стенд>/services_list.txt` (service_id + name; у каждого стенда свой список)
Token options:
- `TOKEN_FILE=/home/naeel/terra/HH-MM-SS.token`, or
@@ -106,7 +106,7 @@ export S3_SECRET_KEY=...
- 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` — источник правды по тому, какие сервисы генерируются.
- `TOOLS/config/<стенд>/services_list.txt` — источник правды по тому, какие сервисы генерируются (у каждого стенда свой).
- Если меняется версия провайдера — обновить `provider/main.go` (ранее `universal_rebuild/main.go` — устаревший путь).
## One-time GPG bootstrap (do this once, keep the key stable)
+9 -8
View File
@@ -2,7 +2,7 @@
## 1. Где прописывать
Единственная точка входа — `devops/config/services_list.txt` (или профильный `profiles/{stand}/services_list.txt`).
Единственная точка входа — `TOOLS/config/<стенд>/services_list.txt` (у каждого стенда свой список).
Формат строки:
```
@@ -158,21 +158,22 @@ operations:
## 8. Быстрый старт: добавляем новый сервис
```bash
# 1. Добавить строку в services_list.txt
echo "200 my_new_service # Моя новая услуга" >> devops/config/services_list.txt
# 1. Добавить строку в список сервисов нужного стенда
# (TOOLS/config/dev|test|prod/services_list.txt)
echo "200 my_new_service # Моя новая услуга" >> TOOLS/config/test/services_list.txt
# 2. Сгенерировать YAML (test-стенд)
devops/01_generate_yamls.sh --profile devops/profiles/test
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/test
# 3. Сгенерировать Go-код + доки
devops/02_generate_resources_and_docs_v2.sh --profile devops/profiles/test
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/test
# 4. Проверить что появился файл
ls devops/profiles/test/generated/resources_yaml/200_my_new_service.yaml
ls devops/profiles/test/generated/go/200_my_new_service_resource.go
ls generated/test/resources_yaml/200_my_new_service.yaml
ls generated/test/go/200_my_new_service_resource.go
# 5. Собрать и задеплоить провайдер
devops/03_build_and_upload_provider.sh --profile devops/profiles/test
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/test
# 6. Написать тестовый манифест в TEST_STAND/my_new_service/main.tf
# 7. terraform init && terraform plan && terraform apply
+1 -1
View File
@@ -50,7 +50,7 @@ cd /home/naeel/TF/tf_provider
| Пайплайн публикации документации | `../DOCS_PIPELINE/README.md`, `../DOCS_PIPELINE/publish-docs.sh` |
| Правила генерации кода провайдера (ОБЯЗАТЕЛЬНЫ для генератора) | `../TOOLS/ARCHITECTURE.md` |
| Скрипты пайплайна | `../TOOLS/scripts/` |
| Конфиги стендов и общий реестр | `../TOOLS/config/` (`registry.env`, `<стенд>/profile.env`, `services_list.txt`) |
| Конфиги стендов и общий реестр | `../TOOLS/config/` (`registry.env`, `<стенд>/profile.env`, `<стенд>/services_list.txt`) |
| Секреты (не коммитить) | `../secrets/` |
| Текущая задача по IaC/`modify` | `../NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md` |
@@ -0,0 +1,116 @@
# Промпт для DeepSeek Pro — план правок кода/документации/архитектуры + стратегия проверки
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
**Дата:** 2026-09-30. **Стенд-источник фактов:** `dev`.
---
## Роль и исполнитель
- Ты — архитектор / ведущий инженер.
- Ты **НЕ пишешь код** и **НЕ меняешь файлы**. Только анализ + план.
- Результат твоей работы — **план правок** (код, документация, архитектура) и **план проверки/тестов**.
- **Исполнять будет другой агент (Copilot)** — строго по твоему плану. Поэтому план должен быть
исполнимым: точные пути файлов, функции, что именно менять, чем проверять.
## Вводная
- Главное — **сам провайдер**: устойчивость, корректность, отсутствие вечных diff.
- Модификаторы (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`) — новая, возможно сложная, но
**НЕБОЛЬШАЯ** часть сервиса. Не раздувай их.
- Ниже — **ГИПОТЕЗЫ** предыдущего анализа. Они **НЕ подтверждены замером**.
Твоя первая задача — проверить их по коду и разделить на «подтверждено / опровергнуто / нужен замер».
## Гипотезы к проверке
### Группа A — ядро (приоритет)
- **A1.** 401 не ретраится: `provider/internal/core/instance_state.go:39` зовёт `doRequest` один раз;
`isRetryable` в `provider/internal/core/http.go:118` = {429,502,503,504} без 401.
Противоречит `TOOLS/ARCHITECTURE.md:105-108` («GetInstanceState retry 401», «doRequest treats 401 as retryable»).
- **A2.** Ретрай только для GET (`http.go:88`); POST (`/instanceOperations`, `/instanceOperationCfsParams`,
`run`) обрывается на транзиентном 502/503/сети → возможны осиротевшая операция/инстанс; далее срабатывает
страж дубликатов (`instance_find.go:168`).
- **A3.** Modify может оборваться **после** создания операции, если чтение live-параметров сбойнуло:
`operation_run.go:150` и `operation_run_bycode.go:108` (`instanceLiveParams` err → `return`).
- **A4.** Zero-value fallback угадывает тип по **подстроке** имени параметра при пустом `dataType`:
`params.go:47` (ищет `array/list/map/json` в `name/code/label`).
- **A5.** В `Read` нормализация значения только для `jsonEnv`/ref_svc: `state_refresh.go:143` и `:168`;
регистровая нормализация только в ref_svc-пути (`refsvc_resolve.go:29`) → возможен вечный diff.
### Группа B — модификаторы (второстепенно)
- **B1.** `TOOLS/ARCHITECTURE.md:225` («Exception Registry») ссылается на реестр `serviceSpecificModifiers`
в `TOOLS/yaml-generator/main.go`, которого **нет** (удалён при рефакторинге; см. комментарий `main.go:80-90`).
- **B2.** Спека не описывает **ручные сервисные оверлеи** (`provider/internal/resources_core/org_ip_allocation_resource.go`,
`nsxt_snat_resource.go`) — нет категории «ручной сервисный ресурс», неясно, входит ли `resources_core` в «core».
- **B3.** Страж `TOOLS/scripts/check_hardcoded_service_ids.sh:15` сканирует **только** `TOOLS/`
и ловит **только** `.ServiceID==N`/`.ID==N` → hardcoded `19`/`22`/`"no-needed"` вне охвата.
- **B4.** Порядок «edge → аллокация» не гарантируется провайдером — держится на пользовательском `depends_on`
(`DEV_STAND/FullPipe/modifiers.tf`).
- **B5.** У `nubes_vc_org_ip_allocation` нет способа снять аллокацию через атрибут (пустой массив запрещён) — только `destroy`.
- **B6.** `modify` выполняется всегда (нет pre-check идемпотентности), хотя idempotent-путь в ядре есть.
- **B7.** `ImportState` модификаторов не заполняет Required-атрибуты.
- **B8.** `modifiers.yaml` (оверлей) **не существует**, при этом генерируемый слой модификаторов готов
(`TOOLS/resource-generator/internal/loader/loader.go:57-86`, `internal/templates/modifier.go`) и не используется.
- **B9.** Три несогласованных словаря жизненного цикла: у генерируемых ресурсов `suspend_on_destroy`/`keep_on_destroy`,
у генерируемых модификаторов `delete_strategy` (compile-time), у ручных модификаторов снова `keep_on_destroy`.
## Что нужно на выходе (строго в этом порядке)
**A. Верификация гипотез**
Таблица: `№ | подтверждено / опровергнуто / нужен замер | опора (файл:строка) | примечание`.
Опровергнутые — обосновать, почему вывод неверен.
**B. План правок кода**
Таблица: `№ | файл | функция/место | что изменить | зачем | риск (низк/сред/высок) | ломает ли совместимость`.
Только правки, вытекающие из подтверждённых пунктов. Никаких «заодно улучшим».
**C. План правок документации и архитектуры**
Что именно и в каком файле (`TOOLS/ARCHITECTURE.md`, `README.md`, `VERSIONS.md`, `HOW_TO/**`, `docs/**`).
Отдельно: какие **архитектурные решения** надо зафиксировать (напр. единый словарь жизненного цикла).
**D. План проверки и тестирования**
Для каждой правки — три уровня:
1. **Юнит/пакетный тест** — какой пакет, что проверяет, где лежит (есть примеры: `provider/internal/resources_core/*_test.go`,
`TOOLS/resource-generator/internal/loader/loader_modifier_test.go`).
2. **Интеграционная проверка** — `terraform plan` / `apply` на `DEV_STAND/FullPipe`: что запустить, что ожидать
в выводе, на что смотреть (с учётом: `apply`/`destroy` выполняет **владелец**, не агент).
3. **Регрессия** — что ещё может сломаться и как это поймать.
Плюс статические стражи: `TOOLS/scripts/check_generated_drift.sh`, `check_hardcoded_service_ids.sh`,
сборка `03_build_and_upload_provider.sh`, `dev-materialize.sh`.
**E. Порядок работ**
Шаги, сгруппированные в **отдельные коммиты**, от безопасных к рискованным. Для каждого шага — 1 строка:
что делаем и как проверяем, после чего фиксируем коммитом.
**F. Открытые вопросы**
Только то, что нельзя выяснить из кода (значения, решения владельца).
## Ограничения
- **Ничего не менять**: не править файлы, не коммитить, не запускать `terraform`/`go`.
- `terraform apply` и `destroy` — **только владелец**.
- Каждое утверждение — с `файл:строка`. **Факт и предположение разделяй явно.**
- Бюджет: читать **не более ~20 файлов**; ответ — компактный, таблицами, **без воды и без «а ещё могу»**.
- Не предлагать переписывание с нуля без доказанной необходимости.
## Границы доступа
Разрешено читать:
- `TOOLS/**` (архитектура, генераторы, скрипты, конфиги)
- `provider/**` (кроме `artifacts/`, `bin/`, `generated/`)
- `generated/dev/resources_yaml/**`
- `docs/**`, `HOW_TO/**`, `README.md`, `VERSIONS.md`
- `DEV_STAND/FullPipe/**` (пример использования)
Запрещено: `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `secrets/**`, `! /`, `.git` (история коммитов).
Нужен файл вне списка — задай вопрос, не читай.
## Формат ответа
- Сжато, тезисами, таблицами. Каждое утверждение проверяемо (`файл:строка`).
- Никаких вступлений, повторения вводной, «лирики».
## Стоп-правило
Задание неоднозначно или данных не хватает — **остановиться и задать один короткий вопрос**.
Не достраивать смысл и не действовать по догадке.
@@ -0,0 +1,43 @@
# Opus — код-ревью правок (раунд 5, 2026-09-30)
**Репо:** `/home/naeel/TF/tf_provider` (Go, Terraform Plugin Framework). Правки уже влиты в `master`,
`go build ./...` и `go test ./internal/... -short` — зелёные. Нужно только ревью.
## Формат ответа (ЖЁСТКО)
- **Только дефекты.** По 1 строке: `файл:строка` → что не так → чем грозит.
- **Весь ответ ≤ 10 строк.** Дефектов нет — ответ «ок».
- Без похвал, пересказа, «а ещё можно», без предложений рефакторинга.
## Что ревьюить (ровно эти коммиты)
```
git --no-pager show 383f8ea c5a4499 ea75cac 8519ba0 9da9766
# или сразу:
git --no-pager diff 047d53a^..9da9766 -- provider/ TOOLS/
```
Файлы:
- `provider/internal/core/http.go` — 401 в `isRetryable`
- `provider/internal/core/modifier_compare.go` — `modifierDesiredEqualsLive`, `modifierValuesEqual`, `modifierCodeMap`
- `provider/internal/core/operation_run_bycode.go` — idempotency pre-check по live
- `provider/internal/core/instance_create.go` — `instanceUid` возвращается вместе с ошибкой
- `provider/internal/resources_core/nsxt_snat_resource.go`, `org_ip_allocation_resource.go` — `ImportState`, `ByIdempotent`
- `TOOLS/resource-generator/internal/templates/instance.go` — partial state в `Create`
- `TOOLS/scripts/check_hardcoded_service_ids.sh` — расширение области/паттернов
## Вопросы (ответ — по 1 строке на каждый, дефект или «ок»)
1. **Q5 partial state.** `Create`: при `err != nil && id != ""` вызывается `resp.State.Set` с планом,
затем `AddError`. Не нарушает ли это контракт framework (допустим ли partial state при ошибке)?
2. **Live pre-check.** `modifierDesiredEqualsLive` ищет live-значение через `lookupLiveParam`
(ключи `Code`/`SvcOperationCfsParam`/`Name`/`Label`). Достаточно ли этого, чтобы не пропустить
нужный `modify`?
3. **`isRetryable` + 401.** Не создаёт ли ретрай 401 ложных повторов там, где это опасно (GET-пути)?
4. **`ImportState`.** Запись Required-атрибутов в `ImportState` не конфликтует ли с последующим `Read`?
5. Что-то ещё критичное в этих диффах — 1 строка.
## Границы доступа
Разрешено: `provider/internal/**`, `TOOLS/**`, `git show`/`git diff` по перечисленным коммитам.
Запрещено: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, стенды. Нужен файл вне списка → вопрос.
@@ -0,0 +1,106 @@
# Промпт для Opus 4.8 — анализ архитектуры провайдера + ресурсов-модификаторов
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
**Дата:** 2026-09-30. **Стенд-источник фактов:** `dev`.
---
## Роль и режим работы
- Ты — архитектор/ревьюер универсального Terraform-провайдера.
- Работаем **в диалоге**: я (агент) передаю твои вопросы пользователю и возвращаю его ответы.
- Вопрос задавай ТОЛЬКО если без него ответить нельзя. Максимум 1–2 вопроса за раз, предельно коротко.
- Не догадываться. Нет данных — вопрос, а не допущение.
- Область не расширять: отвечать ровно на поставленную задачу.
## Задача
Проанализировать архитектуру универсального провайдера Nubes и встроенный в неё слой
**ресурсов-модификаторов** — отдельных ресурсов, которые вызывают операцию `modify`
у родительского инстанса (когда нужного параметра нет в операции `create`).
Оценить:
1. Как устроена архитектура по слоям и как течёт поток данных (API → YAML → код → API).
2. Соответствие заявленной спеки (`TOOLS/ARCHITECTURE.md`) фактической реализации — все
расхождения, с указанием `файл:строка`.
3. Корректность жизненного цикла модификаторов: `Create` / `Read` / `Update` / `Delete`,
идемпотентность, дрейф (drift), поведение при `replace` / повторном `apply`, импорт.
4. Место модификаторов в универсальном ядре: где и как нарушается принцип
«ядро универсально, доменные знания — только данные». Насколько оправдано текущее
решение (ручные Go-ресурсы, зарегистрированные поверх генерируемых).
5. Границы ответственности: что модификатор делает сам, что отдаёт платформе; как
выражается обратная операция (откат при `destroy`, значение «выключено»).
6. Риски и топ-проблемы — по убыванию критичности, каждое с `файл:строка`.
## Границы доступа (ЖЁСТКО)
Читать РАЗРЕШЕНО **только** файлы из списка ниже. Всё остальное — ЗАПРЕЩЕНО, в частности:
- `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `docs/**`, `site/**`, `site_test/**`,
`apps/**`, `charts/**`, `FIYR_MGU/**`, `gateway/**`, `scripts/**`, `secrets/**`,
`tfflaskcrud/**`, `tfluceecrud/**`, `tfnodejscrud/**`, `DEV_STAND/**` (кроме одного файла
из списка), `TEST_STAND/**`, `PROD_STAND/**`, `provider/artifacts/**`, `provider/bin/**`;
- история git (`git log`, `git show`, `git diff` с коммитами), коммиты, теги, ветки;
- любой файл репозитория, которого нет в списке ниже.
Нужен файл вне списка → НЕ читать, а задать мне вопрос.
## Файлы к изучению (исчерпывающий список)
### Группа 1. Архитектура (спека)
- `TOOLS/ARCHITECTURE.md`
### Группа 2. Ресурсы-модификаторы и их регистрация
- `provider/internal/provider/provider.go`
- `provider/internal/resources_core/org_ip_allocation_resource.go`
- `provider/internal/resources_core/nsxt_snat_resource.go`
- `provider/internal/resources_core/org_ip_allocation_test.go`
### Группа 3. Рантайм-зависимости модификаторов (ядро)
- `provider/internal/core/client.go`
- `provider/internal/core/operation_run_bycode.go`
- `provider/internal/core/instance_params.go`
- `provider/internal/core/refsvc_resolve.go`
- `provider/internal/resources_core/resource_diagnostics.go`
- `provider/internal/resources_core/crud.go`
### Группа 4. Генератор (как рождается «универсальная» часть)
- `TOOLS/yaml-generator/main.go`
- `TOOLS/resource-generator/internal/loader/loader.go`
### Группа 5. Факты API (спеки операций/параметров)
- `generated/dev/resources_yaml/19_vc_org.yaml`
- `generated/dev/resources_yaml/22_vc_nsxt.yaml`
### Группа 6. Применение модификаторов (композиция цепочки)
- `DEV_STAND/FullPipe/modifiers.tf`
### Группа 7. Только если без них нельзя ответить (иначе не открывать)
- `provider/internal/resources_core/state_refresh.go`
- `provider/internal/resources_core/params_compare.go`
- `provider/internal/resources_core/helpers.go`
- `TOOLS/resource-generator/main.go`
- `TOOLS/resource-generator/internal/templates/instance.go`
## Что нужно на выходе
Структурированный отчёт, разделы строго в этом порядке:
1. **Устройство архитектуры** — слои и поток данных, 5–10 строк.
2. **Спека ↔ код** — список расхождений `ARCHITECTURE.md` с реализацией (`файл:строка`).
3. **Дефекты и риски модификаторов** — по убыванию критичности. По каждому:
суть → место (`файл:строка`) → последствие → предлагаемое направление (одна строка).
4. **Открытые вопросы** — списком, если есть.
## Формат ответа
- Максимально сжато: тезисы, без вступлений, воды и «лирики».
- Каждое утверждение проверяемо: ссылка `файл:строка`.
- Код — только короткие фрагменты, и лишь если без них тезис не понятен.
- Никаких «а ещё могу», никаких предложений расширить работу.
## Правило «стоп»
Если задание неоднозначно или данных не хватает — **остановиться и задать один короткий
вопрос**. Не достраивать смысл и не действовать по догадке.
@@ -0,0 +1,91 @@
# Промпт для Opus 4.8 — РАУНД 2: замечания к отчёту (2026-09-30)
> Продолжение диалога. Раунд 1 — `prompt_for_opus_provider_architecture_modifiers.md`.
> Формат ответа тот же: сжато, тезисы, `файл:строка`, без догадок. Границы доступа — как в раунде 1
> (плюс список из §4 ниже). `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, git-история — по-прежнему ЗАПРЕЩЕНЫ.
---
## 1. Зачтено (переделывать НЕ надо)
`S1`, `S5`, `R1`, `R4` — приняты. Не повторяй их в ответе.
## 2. Замечания — обязательны к отработке
**M1. Номера строк не сходятся.**
Ты дал `provider.go:199-204` для регистрации модификаторов. По моему чтению файла (начиная со
строки 180) функция `Resources()` находится примерно на строке 182, и регистрация идёт сразу за ней —
твои 199–204 не сходятся. Требование задания — «каждое утверждение проверяемо».
Действие: перепроверь **каждую** ссылку `файл:строка` в отчёте и дай точные номера; где не сверял —
пометь «не сверено». Без этого отчёт не принимается.
**M2. `R2` — нарушено правило «без догадок».**
Ты утверждаешь, что `check_hardcoded_service_ids.sh` «это не ловит», но этот скрипт **не читал**
(его не было в разрешённом списке). Это догадка, а не факт.
Действие: скрипт теперь разрешён (см. §4). Либо приведи факт из его кода, либо переформулируй в вопрос.
**M3. `S2`/`S4` — проверь основание, иначе они натянуты.**
Правила спеки («No manual edits to **generated** Go code», «Service-specific Go code is fully
**generated** from YAML») писаны про генерируемый код. Ресурсы в `resources_core` — ручные, не
генерируемые. Плюс `S4` («modify всегда через WithDefaults») относится к `Update` инстанс-ресурсов,
а не к отдельному ресурсу-модификатору.
Действие: для каждого из S2/S4 дай **текстуальную опору из спеки** (`TOOLS/ARCHITECTURE.md:строка`)
и переформулируй: это **пробел спеки** (нет категории для ручных оверлеев) или **нарушение**? Если
опоры нет — пункт снять.
**M4. `R5` — обоснуй приоритет или понизь.**
Ты сам пишешь «корректности не нарушает», но ставишь R5 выше R6.
Действие: назови шкалу ранжирования (например: вероятность × последствие × обнаружимость) и
пересчитай порядок; либо понизь R5.
**M5. Главный пробел: устойчивость `Read` и вечный diff.**
Для Terraform это опаснее всего, а разобрано тоньше всего (только R6/импорт).
Действие: разбери построчно, как `Read` модификатора формирует `vip_configure` / `ip_space_name`
из live-состояния и совпадёт ли это с тем, что вернёт `jsonencode` из конфига:
- порядок ключей JSON;
- тип `count` (строка vs число);
- снятие `null` и пустых значений;
- что видит `plan` после `Read` для `keep_on_destroy`.
Отдельно: может ли `Update` инстанс-ресурса (`nubes_vc_org` / `nubes_vc_nsxt`) вклиниться между
apply-ами модификатора и вернуть поле назад (пересечение — это R1, но нужен сценарий во времени).
Если считаешь, что глубокий разбор здесь не нужен — обоснуй одной строкой, почему.
## 3. Что упущено — доработать
**U1. Файла `modifiers.yaml` в репозитории НЕТ.**
Есть только упоминания-комментарии (в `TOOLS/yaml-generator/main.go` и док-заметках). Это не
«параллельный слой», а **задокументированное, но не созданное** наложение.
Действие: переформулируй свой открытый вопрос №1 с учётом этого факта.
**U2. Рассинхронизация словаря жизненного цикла.**
У генерируемых ресурсов — `suspend_on_destroy`, у модификаторов — `keep_on_destroy`. Два имени
для одного смысла, живут в разных ветках кода.
Действие: оцени как архитектурный дефект (единый словарь/контракт жизненного цикла), с `файл:строка`.
**U3. Корневая причина «ручных» модификаторов.**
Генератор умеет `kind: modifier`, но доменное знание (какая операция, имя, inverse) некуда положить —
оверлей не создан.
Действие: сформулируй вывод по вариантам (оверлей-данные vs ручной код vs иное) — одной строкой на вариант.
## 4. Дополнительно РАЗРЕШЕНО читать (только это, сверх раунда 1)
- `TOOLS/scripts/check_hardcoded_service_ids.sh`
- `TOOLS/resource-generator/main.go`
- `TOOLS/resource-generator/internal/templates/instance.go`
- `TOOLS/resource-generator/internal/templates/modifier.go`
- `provider/internal/resources_core/state_refresh.go`
- `provider/internal/resources_core/helpers.go`
- `provider/internal/resources_core/params_compare.go`
Всё остальное — по-прежнему нельзя. Нужен файл вне списка → вопрос мне, не чтение.
## 5. Формат ответа
- **Только дельта**: что исправляешь, что снимаешь, чем обосновываешь; новые находки по M5/U2.
- Полный отчёт заново не переписывать.
- Как в раунде 1: тезисы, `файл:строка`, без «лирики» и без «а ещё могу».
## 6. Разрешение копать глубже
Если для честного ответа нужно копнуть глубже (в т.ч. по файлам вне §4) — **скажи прямо**, каких
файлов не хватает, и задай вопрос. Если глубина не нужна — так и напиши по конкретному пункту.
@@ -0,0 +1,59 @@
# Промпт для Opus 4.8 — РАУНД 3: сам провайдер (устойчивость и корректность)
> Продолжение диалога. Раунды 1–2 — `HISTORY/OPUS/2026-09-30_provider_architecture_modifiers_dialog.md`.
> Режим и формат ответа — как раньше: сжато, тезисы, `файл:строка`, без догадок.
---
## Вводная (смена приоритета)
Модификаторы (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`) — новая, возможно сложная, но
**НЕБОЛЬШАЯ** часть сервиса. Чрезмерный упор на них не нужен.
**Главное — САМ провайдер: его устойчивость и корректность.**
- `M6` (доступ к двум файлам для доразбора R1) — **снять**; углубление по модификаторам больше не требуется.
- Прошлые находки (`S1`, `S2`, `R2`–`R6`, `U1`–`U3`) **не повторять**.
## Бюджет (жёстко — экономим токены)
- Прочитать **не более 10 файлов** суммарно. Ранее прочитанные повторно не открывать.
- Ответ — **не более 5 находок**, каждая **не более 3 строк**.
- Никаких вступлений, повторения прошлых отчётов, «а ещё могу».
## Что анализировать (провайдер целиком)
1. **Жизненный цикл инстанс-ресурса:** `create` / adopt / `suspend` / resume / `modify` / redeploy /
`delete` и повторный `apply` — где теряется корректность состояния.
2. **Досылка и нормализация параметров** (`WithDefaults`, zero-value fallback, дефолты `map-fixed`,
регистр UUID): где риск затереть значение или получить ложный diff.
3. **`Read` / refresh:** устойчив ли state у генерируемых ресурсов; где возможен вечный diff.
4. **Устойчивость ядра:** ретраи и ошибки, `LockInstance`, идемпотентность, поведение при дубликатах
и параллельных `apply`.
5. **Генератор → код:** какие классы дефектов порождает шаблон.
## Границы доступа
Разрешено читать (только это):
- `provider/internal/core/**`
- `provider/internal/resources_core/**`
- `provider/internal/provider/provider.go`
- `TOOLS/resource-generator/internal/templates/**`
- `TOOLS/resource-generator/internal/params/params.go`
- `TOOLS/resource-generator/internal/helpers/helpers.go`
- `TOOLS/resource-generator/internal/loader/loader.go`
- `TOOLS/resource-generator/internal/writers/writers.go`
Запрещено: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, `DEV_STAND/**`, `TEST_STAND/**`,
`PROD_STAND/**`, git-история. Нужен файл вне списка → вопрос мне, не чтение.
## Формат ответа
- **ТОП-5 находок** по убыванию критичности: суть → место (`файл:строка`) → последствие (≤ 3 строк).
- Затем **одна строка**: что из этого проверяемо только замером (прогон `apply`/`plan`), а не чтением.
- Находок меньше пяти — столько, сколько есть. Водой не добивать.
## Стоп-правило
Не хватает файла или данных — один короткий вопрос. Не догадываться.
@@ -0,0 +1,43 @@
# Opus — раунд 4: короткие решения (2026-09-30)
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
## Формат ответа (ЖЁСТКО)
- На каждый вопрос — **1–3 строки**, с `файл:строка`.
- **Весь ответ ≤ 25 строк.** Без вступлений, пересказа, «а ещё могу», без повторения вопросов.
- Не хватает данных — одна короткая строка-вопрос, не догадка.
## Уже сделано — не обсуждать
401 в `isRetryable`; страж хардкодов покрыл `provider/`; idempotency pre-check переведён на live
(`core/modifier_compare.go`); `ImportState` модификаторов заполняет Required.
## Вопросы
**Q1. Retry POST.** `core/http.go:87-108` ретраит только GET. Слепой retry создающего
`POST /instanceOperations` рискует дубликатом операции. Как правильно: (а) не ретраить +
задокументировать; (б) ретраить только безопасный POST (`validate-cfs`); (в) иное?
**Q2. Осиротевшая операция.** `core/operation_run.go:150`, `core/operation_run_bycode.go:108`: при
сбое `instanceLiveParams` уже созданная операция остаётся невыполненной. Продолжение с fallback
вернёт reset-баг (защита поставлена сознательно). Есть ли безопасный третий путь (cancel/delete
операции через API) или оставить как есть? Нужен API-метод — назови его.
**Q3. Zero-value по подстроке имени.** `core/params.go:47-59`: при пустом `dataType` тип угадывается
по `name/code/label`. Пути применения: `instance_create.go:105-113` (required без значения), досылка
modify. Менять (убрать угадывание) или оставить?
**Q4. Словарь жизненного цикла.** Три несогласованных контракта: `suspend_on_destroy`/`keep_on_destroy`
(генерируемые ресурсы), `delete_strategy` (генерируемые модификаторы), `keep_on_destroy` (ручные).
Какой единый контракт зафиксировать в `TOOLS/ARCHITECTURE.md`?
**Q5. Что упущено.** Назови **один** самый критичный для устойчивости/корректности **ядра** дефект,
не упомянутый выше: 1 строка — суть → `файл:строка`.
## Границы доступа
Читать: `provider/internal/**`, `TOOLS/ARCHITECTURE.md`,
`TOOLS/resource-generator/internal/{templates,loader,params,helpers}/**`.
Не читать: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, стенды, git-историю. Нужен файл вне списка → вопрос.
+21
View File
@@ -145,6 +145,27 @@ go build -o terraform-provider-nubes
- Генератор может создавать «разбитые» snake_case для CamelCase (например `resource_c_p_u`).
- Это ожидаемо, но если критично — нужен отдельный маппинг (по согласованию).
### 6.5 Регистр UUID в JSON-параметрах (map-fixed) — ОБЯЗАТЕЛЬНО ЗНАТЬ
- Платформа хранит UUID в lowercase, но **сравнивает регистр при create**. Ресурс
`nubes_vc_nsxt` отдаёт `id` в UPPERCASE → `startupConfiguration.vdcUid/nsxtUid`
в верхнем регистре → ошибка «Edge не развёрнут в указанном vDC».
- **Нормализация нужна в ДВУХ разных местах, и они не взаимозаменяемы:**
1. **Сравнение** (план vs state, adopt/suspend/resume, modifier-compare, диагностика) —
`jsonutil.LowercaseUUIDsInText` внутри `JSONStringsEquivalent`, `JsonNormalize()`,
`ParamsMatchForResume`, `normalizeCompareValue`.
2. **Отправка в API** — единственная точка: `resources_core.BuildJSON`
(`provider/internal/resources_core/helpers.go`), её вызывает генератор
(`NestedJSONExpr` в `templates/instance.go`: Create / Modify / Redeploy).
С 30.09 результат оборачивается в `LowercaseUUIDsInText(...)`.
- ⛔ **Не «лечить» это в HCL** (`lower(...)` в конфиге стенда) — это костыль, который
существовал только потому, что путь отправки не нормализовал UUID. Он ломался при
работе из-под Windows на провайдере `2.0.23`.
- Детали, аудит всех мест и границы применимости: `docs/60_strategy/terraform_case_sensitivity_fix.md`
(§10 — регистр при сравнении, §11 — регистр при отправке).
- Не покрыто: скалярные UUID в `normalizeUniversalValueV6` (дефолты create / досылка modify)
и валидация ref-параметров внутри JSON при adopt — см. §11 и HISTORY/2026-09-30.
---
## 7) Проверенная цепочка (dummy)
+2 -1
View File
@@ -116,7 +116,8 @@ API стенда ──01──▶ generated/<стенд>/resources_yaml/*.yaml
- Реестр: `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`.
- Список сервисов для генерации: `TOOLS/config/<стенд>/services_list.txt` (у каждого стенда свой;
общего списка нет — см. `HISTORY/2026-09-30_yaml_pipeline_hardening.md`).
---
+1 -1
View File
@@ -5,7 +5,7 @@
## Как скачать
```bash
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
git clone https://gitea.services.ngcloud.ru/terraform/tf_examples.git
cd tf_examples/CRUD
```
+36 -36
View File
@@ -9,52 +9,52 @@ locals {
# PostgreSQL — общая БД для всех трёх приложений
# ═══════════════════════════════════════════════════════════════════════════
pg_resource_name = "pg4crud2" # имя ресурса в Nubes
pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра)
pg_memory = 512 # память в MB
pg_replicas = 1 # количество реплик
pg_disk = 10 # диск в GB
pg_version = "17" # версия PostgreSQL
pg_retain = 14 # дней хранения бэкапов
pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь)
pg_timeout = "11m" # таймаут операций create/modify
pg_resource_name = "pg4crud2" # имя ресурса в Nubes
pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра)
pg_memory = 512 # память в MB
pg_replicas = 1 # количество реплик
pg_disk = 10 # диск в GB
pg_version = "17" # версия PostgreSQL
pg_retain = 14 # дней хранения бэкапов
pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь)
pg_timeout = "11m" # таймаут операций create/modify
# ═══════════════════════════════════════════════════════════════════════════
# PostgreSQL — пользователь и база данных
# ═══════════════════════════════════════════════════════════════════════════
pg_username = "user4crudpg" # имя пользователя БД
pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы)
pg_db_name = "db4crudpg" # имя базы данных
pg_username = "user4crudpg" # имя пользователя БД
pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы)
pg_db_name = "db4crudpg" # имя базы данных
# ═══════════════════════════════════════════════════════════════════════════
# Lucee — CFML-приложение (сервис 94)
# ═══════════════════════════════════════════════════════════════════════════
lucee_resource_name = "crud-lucee" # имя ресурса в Nubes
lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
lucee_version = "5.4" # версия Lucee (CFML engine)
lucee_git_path = "https://gitea.services.ngcloud.ru/Nail/tfluceecrud.git"
lucee_cpu = 300 # CPU в millicores
lucee_memory = 512 # память в MB
lucee_replicas = 1 # количество реплик
lucee_resource_name = "crud-lucee" # имя ресурса в Nubes
lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
lucee_version = "5.4" # версия Lucee (CFML engine)
lucee_git_path = "https://gitea.services.ngcloud.ru/terraform/tfluceecrud.git"
lucee_cpu = 300 # CPU в millicores
lucee_memory = 512 # память в MB
lucee_replicas = 1 # количество реплик
# ═══════════════════════════════════════════════════════════════════════════
# Таблица CRUD — общая для всех трёх приложений
# ═══════════════════════════════════════════════════════════════════════════
crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env)
crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env)
# ═══════════════════════════════════════════════════════════════════════════
# Flask — Python-приложение (сервис 89)
# ═══════════════════════════════════════════════════════════════════════════
flask_resource_name = "crud-flask" # имя ресурса в Nubes
flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
flask_git_path = "https://gitea.services.ngcloud.ru/Nail/tfflaskcrud.git"
flask_cpu = 300 # CPU в millicores
flask_memory = 512 # память в MB
flask_replicas = 1 # количество реплик
flask_resource_name = "crud-flask" # имя ресурса в Nubes
flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
flask_git_path = "https://gitea.services.ngcloud.ru/terraform/tfflaskcrud.git"
flask_cpu = 300 # CPU в millicores
flask_memory = 512 # память в MB
flask_replicas = 1 # количество реплик
# ═══════════════════════════════════════════════════════════════════════════
# Node.js — Express-приложение (сервис 95)
@@ -62,11 +62,11 @@ locals {
nodejs_resource_name = "crud-nodejs" # имя ресурса в Nubes
nodejs_domain = "tfnodejs" # домен (станет tfnodejs.<суффикс>.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
nodejs_git_path = "https://gitea.services.ngcloud.ru/Nail/tfnodejscrud.git"
nodejs_cpu = 300 # CPU в millicores
nodejs_memory = 512 # память в MB
nodejs_replicas = 1 # количество реплик
nodejs_timeout = "15m" # таймаут операций create/modify
nodejs_git_path = "https://gitea.services.ngcloud.ru/terraform/tfnodejscrud.git"
nodejs_cpu = 300 # CPU в millicores
nodejs_memory = 512 # память в MB
nodejs_replicas = 1 # количество реплик
nodejs_timeout = "15m" # таймаут операций create/modify
# ═══════════════════════════════════════════════════════════════════════════
# JDBC — параметры подключения Lucee к PostgreSQL
@@ -75,14 +75,14 @@ locals {
jdbc_class = "org.postgresql.Driver"
jdbc_bundle_name = "org.postgresql.jdbc"
jdbc_bundle_version = "42.6.0"
jdbc_conn_limit = "5" # макс. количество соединений
jdbc_live_timeout = "15" # таймаут неактивного соединения (минут)
jdbc_validate = "false" # валидация соединения при выдаче из пула
jdbc_conn_limit = "5" # макс. количество соединений
jdbc_live_timeout = "15" # таймаут неактивного соединения (минут)
jdbc_validate = "false" # валидация соединения при выдаче из пула
# ═══════════════════════════════════════════════════════════════════════════
# PostgreSQL — общие параметры подключения
# ═══════════════════════════════════════════════════════════════════════════
pg_port = "5432" # порт PostgreSQL
pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS)
pg_port = "5432" # порт PostgreSQL
pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS)
}
+277
View File
@@ -0,0 +1,277 @@
# Provider Architecture (PRIMARY SOURCE OF TRUTH)
**⛔ THIS FILE IS THE FOUNDATION. ALL CODE AND SCRIPTS ARE DERIVED FROM IT.**
This document defines the project-wide architecture and rules for generation,
provider behavior, and documentation. Any change to provider logic MUST be
reflected here FIRST, then implemented in `gen_v2` and other tools.
## Core Principles
1) YAML per service is generated ONLY from API data.
2) The provider core (`provider/internal/core`) is universal and must not contain
service-specific logic.
3) Service-specific Go code (`provider/internal/resources_gen`) is fully generated
from YAML. No manual edits to generated code.
4) Documentation is generated from the same YAML.
5) Build artifacts for 3 OS targets are published to the registry, and docs are
published to the website.
6) `TOOLS/ARCHITECTURE.md` (this file) is the primary spec. Code follows.
## Service Selection
- The inclusion list is defined by: `TOOLS/config/<dev|test|prod>/services_list.txt`
(по одному списку на стенд; repo-relative path)
- Each line starts with service_id, followed by service name/alias.
- Operation timeouts source is defined by: `TOOLS/config/<dev|test|prod>/operation_timeouts.json`.
## API Endpoint
The provider supports two API styles, auto-detected by `NUBES_API_ENDPOINT`:
- **Legacy proxy**: contains `index.cfm` → `?endpoint=/path`
- **REST Gateway**: no `index.cfm` → direct path concatenation
## Unified YAML (Per Service)
One YAML file per service. This is the only input for:
- Provider code generation
- Documentation generation
- Validation rules
Required top-level fields:
- name
- service_id
- service_display_name
- service_short_name
- lifecycle
- outputs
- operations
- service_man
### Operations: Kinds and Rules
Each operation has a kind:
**instance** — CRUD for the service instance, plus suspend/resume:
| API action | Terraform behavior |
|---|---|
| create | `terraform apply` (new resource) |
| delete | `terraform destroy` |
| modify | `terraform apply` (params changed) |
| suspend | `terraform destroy` when `suspend_on_destroy=true` |
| resume | `terraform apply` when `adopt_existing_on_create=true` |
**subresource** — CRUD for objects inside the service (users, databases, topics):
- Exposed as separate resources: `nubes_{service}_{subresource}`
- Identity = `{parent_instance_uid, subresource_key}` (e.g. `{postgres_id, username}`)
- Supports `adopt_existing_on_create` — if subresource already exists, adopt it instead of failing
**action** — one-shot operations. **Only `redeploy` is included**:
- `redeploy` → **inline**: field `git_revision` in the main resource. When it changes, call redeploy instead of (or after) modify
- `restart`, `recovery`, `reconcile` → **excluded**. These are manual operational tasks, performed via UI only. Reason:
- `restart` — modify handles pod restart when needed
- `recovery` — creates a new instance from backup, not a modification of existing
- `reconcile` — sync after manual changes; Terraform owns its own state
### Idempotency Rules
- instance CRUD is idempotent via standard Terraform behavior.
- subresource CRUD is idempotent by resource identity.
- `redeploy`: idempotent via `git_revision` field — if unchanged, no redeploy
### Lifecycle Behavior
- For `instance` resources with `suspend`/`resume`, use explicit flags:
`adopt_existing_on_create` (default `false`) and `suspend_on_destroy` (default `true`).
- Apply decision matrix for suspend-capable services:
- cloud status `missing` or `deleted` -> `create`
- `suspend` + `adopt_existing_on_create=true` + key params match -> `resume` + `adopt`
- `suspend` + `adopt_existing_on_create=false` -> error (explicitly require flag for resume/adopt)
- `suspend` + key params mismatch -> error
- `running` + `adopt_existing_on_create=true` -> adopt/import behavior
- `running` + `adopt_existing_on_create=false` -> error
- `not created` -> error, no auto-adopt/create
- `creating`/`pending`/`failed` -> error
- Conflict diagnostics requirement:
- When `resource_name` already exists and `adopt_existing_on_create=false`, diagnostics must explicitly offer two choices:
1) change `resource_name` to create a new resource;
2) import/adopt existing one by setting `adopt_existing_on_create=true` and re-running `apply`.
- Destroy behavior for suspend-capable services:
- `suspend_on_destroy=true` -> call `suspend`
- `suspend_on_destroy=false` -> remove from Terraform state only (no API call)
### Diagnostics Format
- Lifecycle diagnostics for plan/apply must be multiline and human-readable.
- Include decision reason and controlling flag in message body.
- Print details as separate lines: `resource_name`, `service_id`, `instance_uid`,
`status`, `status_raw`, `operation_pending`, `operation_in_progress`.
## Provider Model
- Core (`provider/internal/core`) is universal: no service-specific logic inside it.
- Generated service resources (`provider/internal/resources_gen`) contain only
schema/params and references.
- Hand-written service resources live in `provider/internal/resources_core` and are
registered in `provider/internal/provider/provider.go` `Resources()`. They are
NOT generated; they must not contain arbitrary service logic, only:
- a schema, and
- wiring between schema fields and the universal core API
(`RunInstanceOperationUniversalByCode`, `ResolveRefSvcParamValue`, etc.).
### API Resilience
- Core MUST retry transient 401 errors from Gateway (3 attempts, exponential backoff).
Gateway may temporarily reject valid JWT tokens.
- **CURRENTLY NOT IMPLEMENTED** — `isRetryable` (`core/http.go`) retries only
{429, 502, 503, 504}, NOT 401, and only for GET. This is a known gap versus the
intent above; fix in `core/http.go` `isRetryable`.
### Generated Code Resilience
- **Zero-value fallback** (`normalizeUniversalValueV6`): если параметр отсутствует
в пользовательском `.tf`, подставлять zero-value по `dataType`:
- `integer` → `"0"`, `boolean` → `"false"`, `map-fixed` → `"{}"`, `array` → `"[]"`, `string` → `""`
- Это предотвращает NullPointerException на стороне API при добавлении новых полей.
- **map-fixed default из DataDescriptor** (`buildMapFixedDefault`): если API возвращает
`dataDescriptor` для `map-fixed`-параметра, а значение отсутствует или равно `"{}"`,
строится JSON из дефолтов sub-параметров (`{"type":"off","durationCA":"175200",...}`).
Это гарантирует что API получит все обязательные sub-поля с их значениями по умолчанию.
- **s3Uid-резолв в map-fixed** (`resolveS3UidInMapFixed`): для map-fixed-параметров
парсится JSON, ищутся ключи по паттерну `s3.*uid` (case-insensitive), значения-не-UUID
резолвятся в UUID через S3 (сервис 12). Не зависит от DataDescriptor API.
Соглашение об именах: любой sub-param с `s3`+`uid` в имени → S3 (12).
- **modify всегда через WithDefaults** (`RunInstanceOperationUniversalWithDefaults`):
modify-операции запрашивают `cfsParams` у API и отправляют все параметры,
включая новые, с дефолтами из API.
- **Nil-guard для nested-параметров** (шаблон `instance.go`): `map-fixed`-параметры
(указатели на вложенные структуры) проверяются на nil перед доступом к sub-полям.
Если состояние создано до добавления нового `map-fixed`-параметра — он будет nil,
и код не должен падать с nil pointer dereference. Вместо этого параметр пропускается,
и zero-value fallback подставит `{}`.
- **Nested-атрибуты с default — Optional без Computed** (`NestedSchemaBlock` в `helpers.go`):
`map-fixed`-параметры с default генерируются как `Optional: true` (без `Computed`),
потому что провайдер не вычисляет nested-значения из API. `Computed` приводил бы к
`unknown`-значению, которое нельзя декодировать в конкретный тип `*Struct`.
- **Merge SubParams union** (`params.go`): при совпадении `Code` параметра в разных
операциях (create/modify) с разными наборами sub-params, `Merge()` объединяет их
union'ом, а не отбрасывает второй. Это гарантирует что структура содержит все поля.
### Subresource Resources
Subresource operations are exposed as standard resources.
Example mapping:
- create_user/delete_user/modify_user => nubes_<service>_user
- create_database/delete_database => nubes_<service>_database
Example HCL:
resource "nubes_postgres_user" "user1" {
postgres_id = nubes_postgres.db.id
username = "app_user"
role = "app_user"
}
### Redeploy (inline action)
Services with `redeploy` operation get a `git_revision` field in the main resource.
Changing `git_revision` triggers `redeploy` instead of `modify`.
Example HCL:
resource "nubes_flask" "app" {
resource_name = "my-flask"
git_revision = "abc123" # ← change this to trigger redeploy
app_configuration = jsonencode({...})
}
## Concurrency and State Locking
**Provider-level guarantees:**
- The provider does NOT implement distributed locking for instance operations.
- Two concurrent `terraform apply` with the same `resource_name` may create duplicate
instances, leading to a «multiple instances found» error on subsequent applies.
**User responsibility:**
- Use Terraform backend with state locking (S3+DynamoDB, etc.).
- Do NOT run `terraform apply` from two workspaces against the same state simultaneously.
- If duplicates occur: delete extras via Cloud Console and re-apply.
**API-side limitations:**
- Nubes API does not enforce unique `displayName` per serviceId.
- The provider cannot atomically guarantee «create-or-adopt» without API support
for conditional creation or name uniqueness constraints.
## Documentation Model
From the unified YAML, generate:
- Resource page: CRUD params, outputs, lifecycle defaults, operations summary
- MAN page: service_man + parameter man blocks
- Resources index: each resource links to its page and its MAN page
## Pipeline Overview (DevOps)
1) Generate unified YAML from API for services_list.txt.
2) Generate provider code from YAML.
3) Generate documentation from YAML.
4) Build provider for linux/windows/darwin.
5) Upload provider artifacts to registry.
6) Build and publish docs to site.
## Lifecycle Vocabulary (single contract)
⚠️ The destroy-behaviour vocabulary is currently INCONSISTENT across resource kinds:
1. generated instance resources: runtime flags `suspend_on_destroy` / `keep_on_destroy`;
2. generated modifiers: compile-time `delete_strategy` (no runtime flag);
3. hand-written modifiers: runtime `keep_on_destroy` only.
All three express the same intent ("what happens to the platform effect on destroy").
Canonical direction: one unified vocabulary/contract for all resource kinds.
## Non-Negotiable Rules
- No manual edits to generated YAML or generated Go code.
- Any change to generated code 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. Service-specific deviations are of two kinds:
1. **Generated modifiers** — a `modify` op that should become a dedicated modifier
resource. The generator (`TOOLS/resource-generator/internal/loader/loader.go`)
supports `kind: modifier` with `delete_strategy` (`noop_warn`/`inverse`/`error`)
and `idempotency` (`none`/`check_before_run`). The overlay data file
`modifiers.yaml` that would drive this is **documented but NOT yet created**;
until then, modifiers are hand-written in `provider/internal/resources_core/`.
(The legacy registry `serviceSpecificModifiers` in `TOOLS/yaml-generator/main.go`
was REMOVED during the 2026-09-23 refactoring.)
2. **Doc examples** — named registry:
| Registry | File | Declares |
|---|---|---|
| `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).
- Doc-example exception = editing the named registry above → visible in diff.
- Modifier exception (until `modifiers.yaml` exists) = hand-written resource in
`provider/internal/resources_core/` + registration in `provider.go`.
- 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).
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# check_hardcoded_service_ids.sh — запрет сервис-специфичных хардкодов по числовому ID.
#
# Ищет сравнения вида svc.ID == N / ServiceID == N / spec.ServiceID == N (N > 0)
# в Go-коде TOOLS/. Исключения должны жить ТОЛЬКО в именованных реестрах (данные):
# - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)
# - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)
#
# Выход: 0 — хардкодов нет; 1 — найдены.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
matches="$(grep -rnE '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' "$ROOT_DIR/TOOLS" --include='*.go' || true)"
if [[ -n "$matches" ]]; then
echo "HARDCODED SERVICE ID FOUND (service-specific logic must live in a registry):" >&2
echo "$matches" >&2
echo "" >&2
echo "Вынеси исключение в один из реестров:" >&2
echo " - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)" >&2
echo " - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)" >&2
echo "См. TOOLS/ARCHITECTURE.md, раздел «Реестр исключений»." >&2
exit 1
fi
echo "OK: no hardcoded service IDs in TOOLS/."
@@ -0,0 +1,169 @@
package core
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"os"
"strings"
"time"
)
// ===== Internal HTTP helpers =====
func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
// User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md).
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
const maxRetries = 3
baseDelay := c.RetryBaseDelay
if baseDelay <= 0 {
baseDelay = 2 * time.Second
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
delay := baseDelay * time.Duration(1<<(attempt-1)) // 2s, 4s, 8s
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, nil, ctx.Err()
}
}
var body io.Reader
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return nil, nil, err
}
body = bytes.NewBuffer(b)
}
reqURL := c.buildURL(path)
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return nil, nil, err
}
// Форсируем новое TCP-соединение для POST/PUT/PATCH: DDoS-Guard может блочить их на keep-alive.
// GET-запросы (поиск, чтение) оставляем на keep-alive — req.Close на них ломает DDoS-Guard.
if method != "GET" {
req.Close = true
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "*/*")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.ApiToken != "" {
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
}
// DEBUG
if isHTTPDebugEnabled() {
reqForDump := req.Clone(req.Context())
reqForDump.Header = sanitizeAuthHeader(req.Header)
dump, _ := httputil.DumpRequestOut(reqForDump, body != nil)
fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump)
}
// DEBUG в файл
if isHTTPDebugEnabled() {
f, _ := os.OpenFile(debugLogPath("nubes_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if f != nil {
fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String())
f.Close()
}
}
resp, err := c.HttpClient.Do(req)
if err != nil {
lastErr = err
// Сетевые ошибки: retry только для идемпотентного GET.
if attempt < maxRetries && method == "GET" {
continue
}
return nil, nil, err
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
if attempt < maxRetries {
continue
}
return nil, nil, err
}
// Retry только для transient-ошибок и только для GET
if resp.StatusCode >= 400 {
if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) {
lastErr = formatAPIError(resp.StatusCode, respBody)
continue
}
return nil, nil, formatAPIError(resp.StatusCode, respBody)
}
return respBody, resp.Header, nil
}
return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr)
}
// isRetryable returns true for transient HTTP errors that can be retried.
func isRetryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || // 429
statusCode == http.StatusServiceUnavailable || // 503
statusCode == http.StatusBadGateway || // 502
statusCode == http.StatusGatewayTimeout // 504
}
func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) {
respBody, headers, err := c.doRequest(ctx, "POST", path, payload)
if err != nil {
return "", err
}
if returnLocation {
if loc := headers.Get("Location"); loc != "" {
return extractUIDFromLocation(loc), nil
}
}
var justId string
if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" {
return justId, nil
}
return "", nil
}
func extractUIDFromLocation(loc string) string {
if loc == "" {
return ""
}
return strings.TrimPrefix(loc, "./")
}
// formatAPIError парсит JSON-ответ API и возвращает читаемое сообщение.
// Если тело не является JSON с полем ERROR — возвращает сырой текст.
func formatAPIError(statusCode int, body []byte) error {
var parsed struct {
Error string `json:"ERROR"`
Detail string `json:"DETAIL"`
}
if json.Unmarshal(body, &parsed) == nil && parsed.Error != "" {
msg := parsed.Error
if d := strings.TrimSpace(parsed.Detail); d != "" {
msg += ": " + d
}
return fmt.Errorf("ошибка API %d: %s", statusCode, msg)
}
return fmt.Errorf("ошибка API %d: %s", statusCode, strings.TrimSpace(string(body)))
}
@@ -0,0 +1,249 @@
package core
import (
"context"
"fmt"
"strings"
"time"
)
// ===== Запуск операций инстанса (params по ID) =====
//
// - RunInstanceOperationUniversal — операция с params по ID (без досылки дефолтов)
// - RunInstanceOperationUniversalWithDefaults — то же + досылка required-дефолтов
// - RunRedeployOperation — redeploy
//
// Операции с params по code — см. operation_run_bycode.go.
// Схема cfsParams (с fallback) — см. operation_cfs.go.
// RunInstanceOperationUniversal runs an available operation (modify/suspend/delete/resume) if possible.
func (c *UniversalClient) RunInstanceOperationUniversal(ctx context.Context, instanceUid string, action string, params map[int]string) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
var opId int
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, action) {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": action,
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции для %s", action)
}
for paramId, value := range params {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
}
}
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
if err != nil {
return err
}
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
}
// RunInstanceOperationUniversalWithDefaults runs an operation and submits required params (including defaults).
func (c *UniversalClient) RunInstanceOperationUniversalWithDefaults(ctx context.Context, instanceUid string, action string, params map[int]string) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
var opId int
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, action) {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": action,
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции для %s", action)
}
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
if err != nil {
return err
}
params, err = c.resolveRefSvcParamValues(ctx, cfsParams, params)
if err != nil {
return err
}
sent := make(map[int]bool)
for paramId, value := range params {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
}
sent[paramId] = true
}
live, liveErr := c.instanceLiveParams(ctx, instanceUid)
if liveErr != nil {
return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr)
}
for _, param := range cfsParams {
if sent[param.SvcOperationCfsParamId] {
continue
}
// Приоритет: live state.params инстанса → paramValue операции → defaultValue.
// paramValue из cfsParams — дефолт формы, не состояние инстанса (см. operation_cfs.go).
// Симметрично runInstanceOperationByCode: если ни одного источника нет — пропускаем
// (иначе уйдёт синтетический "0"/"false"/"[]" и нарушит constraint).
val, hasLive := lookupLiveParam(live, param)
if !hasLive {
if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") &&
(param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") {
continue
}
if param.ParamValue != nil && strings.TrimSpace(*param.ParamValue) != "" {
val = *param.ParamValue
} else if param.DefaultValue != nil {
val = *param.DefaultValue
}
}
val = normalizeUniversalValueV6(val, param)
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
ParamValue: val,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
}
}
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
if err != nil {
return fmt.Errorf("валидация не пройдена: %w", err)
}
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
if err != nil {
return err
}
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
}
// RunRedeployOperation запускает redeploy для сервисов, поддерживающих пересборку из git.
// Если params не nil — отправляет CFS-параметры перед запуском.
func (c *UniversalClient) RunRedeployOperation(ctx context.Context, instanceUid string, timeoutOverride string, params map[int]string) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
opId := 0
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, "redeploy") {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция redeploy недоступна для экземпляра %s", instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": "redeploy",
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию redeploy: %w", err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции redeploy")
}
for paramId, value := range params {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
if _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload); err != nil {
return fmt.Errorf("не удалось установить параметр %d для redeploy: %w", paramId, err)
}
}
if _, _, err := c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}); err != nil {
return err
}
timeout := c.operationTimeoutForContext(ctx, state.ServiceId, "redeploy")
if timeoutOverride != "" {
if d, parseErr := time.ParseDuration(timeoutOverride); parseErr == nil {
timeout = d
}
}
return c.waitForOperationFinish(ctx, opUid, timeout)
}
@@ -0,0 +1,162 @@
package core
import (
"context"
"fmt"
"strings"
)
// RunInstanceOperationUniversalByCode runs an operation using params keyed by code.
// It resolves param codes to IDs via operation manifest, applies defaults, validates, and runs.
func (c *UniversalClient) RunInstanceOperationUniversalByCode(ctx context.Context, instanceUid string, action string, params map[string]string) error {
return c.runInstanceOperationByCode(ctx, instanceUid, action, params, false)
}
// RunInstanceOperationUniversalByIdempotent — то же, но с idempotency pre-check:
// перед run сверяет desired==current и, если ВСЕ поля совпали, пропускает run.
// Применяется для модификаторов с idempotency: check_before_run.
func (c *UniversalClient) RunInstanceOperationUniversalByIdempotent(ctx context.Context, instanceUid string, action string, params map[string]string) error {
return c.runInstanceOperationByCode(ctx, instanceUid, action, params, true)
}
func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instanceUid string, action string, params map[string]string, idempotent bool) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
var opId int
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, action) {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": action,
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции для %s", action)
}
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
if err != nil {
return err
}
// Idempotency pre-check: если все desired уже равны live-значениям — пропускаем run.
// desired = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки.
if idempotent && c.modifierDesiredEqualsCurrent(params, cfsParams) {
return nil
}
codeToParam := make(map[string]universalCfsParam)
for _, p := range cfsParams {
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
codeToParam[key] = p
}
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
codeToParam[key] = p
}
}
paramsByID := map[int]string{}
for code, value := range params {
key := strings.ToLower(strings.TrimSpace(code))
p, ok := codeToParam[key]
if !ok {
return fmt.Errorf("код параметра %s не найден для операции %s", code, action)
}
paramsByID[p.SvcOperationCfsParamId] = value
}
paramsByID, err = c.resolveRefSvcParamValues(ctx, cfsParams, paramsByID)
if err != nil {
return err
}
sent := make(map[int]bool)
for paramId, value := range paramsByID {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
}
sent[paramId] = true
}
live, liveErr := c.instanceLiveParams(ctx, instanceUid)
if liveErr != nil {
return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr)
}
for _, param := range cfsParams {
if sent[param.SvcOperationCfsParamId] {
continue
}
// Дозаполняем ВСЕ незаданные параметры, чтобы бэкенд modify не трактовал
// пропущенный/null как reset-to-default (иначе частичный payload затирает
// create-поля, см. prompt_for_opus_modifier_null_bug.md).
//
// ПРИОРИТЕТ ИСТОЧНИКА: live state.params инстанса → paramValue операции → defaultValue.
// КРИТИЧНО: paramValue из ?fields=cfsParams — дефолт ФОРМЫ операции, не состояние
// инстанса (HAR/edge_.har: needEnableAVI paramValue="false" при live=true → стирало ALB).
// Если ни live, ни paramValue, ни defaultValue НЕТ — пропускаем (не шлём синтетический
// "0"/"false"/"[]", который может нарушить constraint "integer > 0").
val, hasLive := lookupLiveParam(live, param)
if !hasLive {
if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") &&
(param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") {
continue
}
if param.ParamValue != nil {
val = *param.ParamValue
} else if param.DefaultValue != nil {
val = *param.DefaultValue
}
}
val = normalizeUniversalValueV6(val, param)
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
ParamValue: val,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
}
}
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
if err != nil {
return fmt.Errorf("валидация не пройдена: %w", err)
}
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
if err != nil {
return err
}
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
}
@@ -0,0 +1,61 @@
package core
import (
"encoding/json"
"strings"
)
// ===== Нормализация значений CFS-параметров =====
// buildMapFixedDefault строит JSON-объект из дефолтов sub-параметров map-fixed.
func buildMapFixedDefault(param universalCfsParam) string {
result := make(map[string]string, len(param.DataDescriptor))
for key, sub := range param.DataDescriptor {
result[key] = sub.DefaultValue
}
b, err := json.Marshal(result)
if err != nil {
return "{}"
}
return string(b)
}
func normalizeUniversalValueV6(val string, param universalCfsParam) string {
trimmed := strings.TrimSpace(val)
if strings.EqualFold(trimmed, "null") {
trimmed = ""
}
if trimmed == "\"\"" {
trimmed = ""
}
// map-fixed с DataDescriptor: если значение пустое или "{}" — строим JSON из дефолтов sub-params.
dataType := strings.ToLower(param.DataType)
if (dataType == "map-fixed" || strings.HasPrefix(dataType, "map")) && len(param.DataDescriptor) > 0 {
if trimmed == "" || trimmed == "{}" {
return buildMapFixedDefault(param)
}
return trimmed
}
if trimmed != "" {
return trimmed
}
nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam)
if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") {
return "[]"
}
if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") {
return "{}"
}
if strings.Contains(dataType, "integer") || strings.Contains(dataType, "int") {
return "0"
}
if strings.Contains(dataType, "boolean") || strings.Contains(dataType, "bool") {
return "false"
}
return trimmed
}
@@ -0,0 +1,258 @@
package resources_core
import (
"context"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &NsxtSnatResource{}
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
//
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
//
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
type NsxtSnatResource struct {
client *core.UniversalClient
}
type NsxtSnatModel struct {
ID types.String `tfsdk:"id"`
NsxtUID types.String `tfsdk:"nsxt_uid"`
IpSpaceName types.String `tfsdk:"ip_space_name"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// noNeededIpSpace — каноническое значение «SNAT не нужен».
const noNeededIpSpace = "no-needed"
func NewNsxtSnatResource() resource.Resource {
return &NsxtSnatResource{}
}
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
}
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
"Значение `no-needed` выключает SNAT.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"nsxt_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ip_space_name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
"`ipSpaceName = \"no-needed\"`).",
},
},
}
}
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если ключа ещё нет
// (SNAT ни разу не включали, HAR fresh-create) — оставляем текущее значение state.
if raw, ok := live["ipSpaceName"]; ok && strings.TrimSpace(raw) != "" {
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
}
state.ID = types.StringValue(nsxtUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
// Реальная ошибка API (не «шлюза нет») — нельзя молча терять SNAT: ресурс уйдёт из state,
// а SNAT останется включённым.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
)
return
}
unlock := r.client.LockInstance(nsxtUID)
defer unlock()
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
"ipSpaceName": noNeededIpSpace,
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"SNAT выключен",
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
)
}
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
}
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
uid := strings.TrimSpace(nsxtUID.ValueString())
if uid == "" {
return fmt.Errorf("nsxt_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
// Пустую строку молча подменять нельзя (скрытое поведение + риск вечного diff).
// Выключение SNAT — явное каноническое значение "no-needed".
value := strings.TrimSpace(ipSpaceName.ValueString())
if value == "" {
return fmt.Errorf("ip_space_name не может быть пустым: укажите имя ipSpace или %q для выключения SNAT", noNeededIpSpace)
}
unlock := r.client.LockInstance(uid)
defer unlock()
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
// а не с live-состоянием инстанса — можно ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"ipSpaceName": value,
})
}
@@ -0,0 +1,413 @@
package resources_core
import (
"context"
"encoding/json"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &OrgIpAllocationResource{}
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
//
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
// и адресуется здесь по uid.
//
// Семантика операции — replace всего массива: переданное значение полностью заменяет
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
type OrgIpAllocationResource struct {
client *core.UniversalClient
}
type OrgIpAllocationModel struct {
ID types.String `tfsdk:"id"`
Organization types.String `tfsdk:"organization"`
VIPConfigure types.String `tfsdk:"vip_configure"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
type vipAllocation struct {
Name string
Count string
}
func NewOrgIpAllocationResource() resource.Resource {
return &OrgIpAllocationResource{}
}
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
}
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"organization": schema.StringAttribute{
Required: true,
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
"или её UUID.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"vip_configure": schema.StringAttribute{
Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
"`count=0` по каждому элементу).",
},
},
}
}
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
// Организации больше нет — ресурс тоже не нужен.
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
raw, ok := live["vIPConfigure"]
if ok {
liveItems, parseErr := parseVipConfigure(raw)
if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return
}
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
if !vipAllocationsEqual(liveItems, stateItems) {
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
}
}
state.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
// Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state,
// а выделенные IP останутся висеть.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
)
return
}
unlock := r.client.LockInstance(orgUID)
defer unlock()
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
items := []vipAllocation{}
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
)
return
}
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
zero := make([]vipAllocation, 0, len(items))
for _, item := range items {
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
}
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(zero),
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"Квота IP обнулена",
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
)
}
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
}
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
// Резолв делает клиент — тем же путём, что сгенерированный nubes_vc_vdc
// (core.ResolveRefSvcParamValue, сравн. 21_vc_vdc_resource.go).
func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, organization types.String) (string, error) {
if r.client == nil {
return "", fmt.Errorf("клиент не инициализирован")
}
raw := strings.TrimSpace(organization.ValueString())
if raw == "" {
return "", fmt.Errorf("organization обязателен")
}
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
if err != nil {
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
}
resolved = strings.TrimSpace(resolved)
if resolved == "" {
return "", fmt.Errorf("организация %q не найдена", raw)
}
return resolved, nil
}
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID)
if uid == "" {
return fmt.Errorf("organization обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
items, err := parseVipConfigure(vipConfigure.ValueString())
if err != nil {
return err
}
if len(items) == 0 {
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
}
unlock := r.client.LockInstance(uid)
defer unlock()
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(items),
})
}
// parseVipConfigure разбирает значение параметра vIPConfigure.
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
func parseVipConfigure(raw string) ([]vipAllocation, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
}
out := make([]vipAllocation, 0, len(items))
for _, item := range items {
name := ""
if v, ok := item["name"]; ok && v != nil {
name = strings.TrimSpace(fmt.Sprint(v))
}
if name == "" {
continue
}
count := "0"
if v, ok := item["count"]; ok && v != nil {
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
count = parsed
}
}
out = append(out, vipAllocation{Name: name, Count: count})
}
return out, nil
}
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read,
// иначе план и state расходятся по строке — см. vipConfigureCanonical.
func formatVipConfigure(items []vipAllocation) string {
if len(items) == 0 {
return "[]"
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count))
}
return "[" + strings.Join(parts, ",") + "]"
}
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
func vipAllocationsEqual(a, b []vipAllocation) bool {
if len(a) != len(b) {
return false
}
byName := make(map[string]string, len(b))
for _, item := range b {
byName[item.Name] = item.Count
}
for _, item := range a {
count, ok := byName[item.Name]
if !ok || count != item.Count {
return false
}
}
return true
}
@@ -0,0 +1,257 @@
package resources_core
import (
"context"
"reflect"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type StateField struct {
Code string
}
type InputField struct {
Code string
Field string
Type string
}
var (
typeString = reflect.TypeOf(types.String{})
typeBool = reflect.TypeOf(types.Bool{})
typeInt64 = reflect.TypeOf(types.Int64{})
typeMap = reflect.TypeOf(types.Map{})
typeList = reflect.TypeOf(types.List{})
)
func RefreshResourceState[T any](ctx context.Context, client *core.UniversalClient, instanceID string, serviceID int, state T, outputs []StateField, inputs []InputField) (T, diag.Diagnostics) {
var diags diag.Diagnostics
if client == nil || strings.TrimSpace(instanceID) == "" {
return state, diags
}
out, outDiags := FetchInstanceOutputs(ctx, client, instanceID)
diags.Append(outDiags...)
v := reflect.ValueOf(&state).Elem()
if v.Kind() != reflect.Struct {
return state, diags
}
stateOutMap := map[string]string{}
if !out.StateOut.IsNull() && !out.StateOut.IsUnknown() {
mapped, mapDiags := StringMapFromTypesMap(ctx, out.StateOut)
diags.Append(mapDiags...)
if len(mapped) > 0 {
stateOutMap = mapped
}
}
for _, field := range outputs {
code := strings.TrimSpace(field.Code)
if code == "" {
continue
}
fieldName := toCamel(code)
fv := v.FieldByName(fieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
switch code {
case "state_params":
setFieldValue(fv, out.StateParams)
case "state_out":
setFieldValue(fv, out.StateOut)
case "state_params_flat":
setFieldValue(fv, out.StateParamsFlat)
case "state_out_flat":
setFieldValue(fv, out.StateOutFlat)
case "vault_secrets":
setFieldValue(fv, out.VaultSecrets)
case "vault_url":
setFieldValue(fv, out.VaultUrl)
case "vault_user_path":
setFieldValue(fv, out.VaultUserPath)
case "vault_fields":
setFieldValue(fv, out.VaultFields)
default:
if val, ok := stateOutMap[code]; ok {
setFieldValue(fv, ParseString(val))
}
}
}
paramsMap, paramsDiags := StringMapFromTypesMap(ctx, out.StateParams)
diags.Append(paramsDiags...)
if serviceID > 0 && len(paramsMap) > 0 {
paramsMap, paramsDiags = ResolveRefSvcParamDisplayNames(ctx, client, serviceID, paramsMap)
diags.Append(paramsDiags...)
}
resourceRealm := strings.TrimSpace(paramsMap["resourceRealm"])
if resourceRealm != "" {
outFlatMap, flatDiags := StringMapFromTypesMap(ctx, out.StateOutFlat)
diags.Append(flatDiags...)
if len(outFlatMap) > 0 {
fixed := FixInternalConnectMasterSuffix(outFlatMap, resourceRealm)
if fixed {
out.StateOutFlat, diags = mapValueFrom(ctx, outFlatMap, diags)
fv := v.FieldByName(toCamel("state_out_flat"))
if fv.IsValid() && fv.CanSet() {
setFieldValue(fv, out.StateOutFlat)
}
}
}
}
for _, input := range inputs {
code := strings.TrimSpace(input.Code)
if code == "" {
continue
}
// Поле модели достаём ДО проверки наличия значения в API: оно нужно
// и в ветке "API не вернул код" (см. схлопывание unknown → null ниже).
fieldName := strings.TrimSpace(input.Field)
if fieldName == "" {
fieldName = toCamel(code)
}
fv := v.FieldByName(fieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
value, ok := paramsMap[code]
if !ok {
// ИНВАРИАНТ: Computed-атрибут обязан быть KNOWN после apply/read.
//
// Параметры, которые провайдер читает обратно, объявлены в схеме как
// Optional+Computed (см. helpers.ShouldBeOptionalComputed). Если
// пользователь такой параметр не задал, в плане он = unknown, и именно
// провайдер обязан проставить конкретное значение. Когда платформа
// не вернула код в state_params, единственное корректное конкретное
// значение — null.
//
// Если оставить unknown, Terraform упадёт с
// "Provider produced invalid result object after apply: ... was unknown".
if inputFieldUnknown(fv) {
setInputFieldNull(fv)
}
continue
}
if strings.EqualFold(code, "jsonEnv") && fv.Type() == typeString {
// Preserve planned json_env when API returns equivalent JSON with different ordering.
if planned, ok := fv.Interface().(types.String); ok && !planned.IsNull() && !planned.IsUnknown() {
if JSONStringsEquivalent(planned.ValueString(), value) {
fv.Set(reflect.ValueOf(planned))
continue
}
}
}
switch strings.ToLower(strings.TrimSpace(input.Type)) {
case "bool":
if fv.Type() == typeBool {
fv.Set(reflect.ValueOf(ParseBool(value)))
}
case "int", "int64", "number":
if fv.Type() == typeInt64 {
fv.Set(reflect.ValueOf(ParseInt64(value)))
}
default:
if fv.Type() == typeString {
fv.Set(reflect.ValueOf(ParseString(value)))
}
}
}
return state, diags
}
// inputFieldUnknown сообщает, находится ли поле модели в состоянии unknown.
//
// Зачем: соблюдение инварианта «Computed-атрибут обязан быть known после
// apply/read». Если платформа не вернула значение в state_params, unknown
// оставлять нельзя — его надо схлопнуть в null (см. setInputFieldNull).
func inputFieldUnknown(fv reflect.Value) bool {
switch fv.Type() {
case typeString:
v, ok := fv.Interface().(types.String)
return ok && v.IsUnknown()
case typeBool:
v, ok := fv.Interface().(types.Bool)
return ok && v.IsUnknown()
case typeInt64:
v, ok := fv.Interface().(types.Int64)
return ok && v.IsUnknown()
}
return false
}
// setInputFieldNull записывает в поле модели типизированный null.
//
// Зачем: null — это конкретное (known) значение, в отличие от unknown. Именно
// null приводит состояние Terraform в консистентный вид, когда платформа не
// сообщила значение для read-back параметра.
func setInputFieldNull(fv reflect.Value) {
switch fv.Type() {
case typeString:
fv.Set(reflect.ValueOf(types.StringNull()))
case typeBool:
fv.Set(reflect.ValueOf(types.BoolNull()))
case typeInt64:
fv.Set(reflect.ValueOf(types.Int64Null()))
}
}
func setFieldValue(field reflect.Value, value interface{}) {
switch field.Type() {
case typeString:
switch v := value.(type) {
case types.String:
field.Set(reflect.ValueOf(v))
case string:
field.Set(reflect.ValueOf(ParseString(v)))
}
case typeBool:
if v, ok := value.(types.Bool); ok {
field.Set(reflect.ValueOf(v))
}
case typeInt64:
if v, ok := value.(types.Int64); ok {
field.Set(reflect.ValueOf(v))
}
case typeMap:
if v, ok := value.(types.Map); ok {
field.Set(reflect.ValueOf(v))
}
case typeList:
if v, ok := value.(types.List); ok {
field.Set(reflect.ValueOf(v))
}
}
}
func toCamel(s string) string {
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' })
for i, p := range parts {
if len(p) == 0 {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
out := strings.Join(parts, "")
if out == "" {
return "R"
}
first := rune(out[0])
if (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || first == '_' {
return out
}
return "R" + out
}
+78
View File
@@ -0,0 +1,78 @@
# Список языковых моделей и агентов
> Данные перенесены из предоставленного списка. Достоверность названий, параметров и стоимости отдельно не проверялась.
## 1. DeepSeek
| Имя | Размер контекста | Возможности | Вход | Выход |
|---|---:|---|---:|---:|
| DeepSeek V4 Flash | 1M | Инструменты, Видение | Не указана | Не указана |
| DeepSeek V4 Flash Vision Exp | 1M | Инструменты, Видение | Не указана | Не указана |
| DeepSeek V4 Pro | 1M | Инструменты, Видение | Не указана | Не указана |
| DeepSeek V4.1 Hash | 1M | Инструменты, Видение | Не указана | Не указана |
## 2. Copilot
| Имя | Размер контекста | Возможности | Вход | Выход |
|---|---:|---|---:|---:|
| Auto | Не указан | Инструменты | Не указана | Не указана |
| Claude Fable 5 | 1M | Инструменты, Видение | 1000 | 5000 |
| Claude Fable 5.1 | 1M | Инструменты, Видение | 1000 | 5000 |
| Claude Haiku 4.5 | 160K | Инструменты, Видение | 100 | 500 |
| Claude Opus 4.7 | 1M | Инструменты, Видение | 500 | 2500 |
| Claude Opus 4.8 | 1M | Инструменты, Видение | 500 | 2500 |
| Claude Opus 4.8 (fast mode) (Preview) | 1M | Инструменты, Видение | 1000 | 5000 |
| Claude Opus 5 | 1M | Инструменты, Видение | 500 | 2500 |
| Claude Opus 5.5 | 1M | Инструменты, Видение | 400 | 2000 |
| Claude Sonnet 5 | 1M | Инструменты, Видение | 200 | 1000 |
| Gemini 3.5 Flash | 1M | Инструменты, Видение | 150 | 900 |
| Gemini 3.6 Flash | 1M | Инструменты, Видение | 75 | 375 |
| Gemini 3.7 Flash | 1M | Инструменты, Видение | 75 | 375 |
| Gemini 3.8 Flash | 1M | Инструменты, Видение | 75 | 375 |
| GPT-5 mini | 192K | Инструменты, Видение | 25 | 200 |
| GPT-5.3-Codex | 400K | Инструменты, Видение | 175 | 1400 |
| GPT-5.4 | 1M | Инструменты, Видение | 250 | 1500 |
| GPT-5.4 mini | 400K | Инструменты, Видение | 75 | 450 |
| GPT-5.5 | 1M | Инструменты, Видение | 500 | 3000 |
| GPT-5.6 Luna | 1M | Инструменты, Видение | 20 | 120 |
| GPT-5.6 Sol | 1M | Инструменты, Видение | 400 | 2000 |
| GPT-5.6 Terra | 1M | Инструменты, Видение | 200 | 1200 |
| GPT-6 Astra | 1M | Инструменты, Видение | 1000 | 5000 |
| GPT-6 Luna | 1M | Инструменты, Видение | 10 | 50 |
## 3. Общий список языковых моделей
Стоимость указана в кредитах за 1 млн токенов.
| Имя | Размер контекста | Возможности | Вход | Выход |
|---|---:|---|---:|---:|
| Claude Fable 5 | 1M | Инструменты, Видение | 1000 | 5000 |
| Claude Fable 5.1 | 1M | Инструменты, Видение | 1000 | 5000 |
| Claude Haiku 4.5 | 160K | Инструменты, Видение | 100 | 500 |
| Claude Opus 4.7 | 1M | Инструменты, Видение | 500 | 2500 |
| Claude Opus 4.8 | 1M | Инструменты, Видение | 500 | 2500 |
| Claude Opus 4.8 (fast mode) | 1M | Инструменты, Видение | 1000 | 5000 |
| Claude Opus 5 | 1M | Инструменты, Видение | 500 | 2500 |
| Claude Opus 5.5 | 1.1M | Инструменты, Видение | 400 | 2000 |
| Claude Sonnet 5 | 1M | Инструменты, Видение | 200 | 1000 |
| Gemini 3.5 Flash | 1M | Инструменты, Видение | 150 | 900 |
| Gemini 3.6 Flash | 1M | Инструменты, Видение | 75 | 375 |
| Gemini 3.7 Flash | 1M | Инструменты, Видение | 75 | 375 |
| Gemini 3.8 Flash | 1M | Инструменты, Видение | 75 | 375 |
| GPT-5 mini | 192K | Инструменты, Видение | 25 | 200 |
| GPT-5.3-Codex | 400K | Инструменты, Видение | 175 | 1400 |
| GPT-5.4 | 1M | Инструменты, Видение | 250 | 1500 |
| GPT-5.4 mini | 400K | Инструменты, Видение | 75 | 450 |
| GPT-5.5 | 1M | Инструменты, Видение | 500 | 3000 |
| GPT-5.6 Luna | 1M | Инструменты, Видение | 20 | 120 |
| GPT-5.6 Sol | 1M | Инструменты, Видение | 400 | 2000 |
| GPT-5.6 Terra | 1M | Инструменты, Видение | 200 | 1200 |
| GPT-6 Astra | 1.1M | Инструменты, Видение | 1000 | 5000 |
| GPT-6 Luna | 1M | Инструменты, Видение | 10 | 50 |
| GPT-6 Sol | 1M | Инструменты, Видение | 200 | 1000 |
| Grok 4.5 | 500K / 553K | Инструменты, Видение | 200 | 600 |
| Grok 4.6 | 500K / 553K | Инструменты, Видение | 200 | 600 |
| Grok 4.7 | 500K / 553K | Инструменты, Видение | 200 | 600 |
| Kimi K2.7 Code | 256K | Инструменты, Видение | 95 | 400 |
| Kimi K3 | 1M | Инструменты, Видение | 300 | 1500 |
| MAI Code 1.1-Flash | 256K | Инструменты, Видение | 20 | 120 |
+176
View File
@@ -0,0 +1,176 @@
# adidas Terrex Anylander Unisex Trail Shoes
## Навигация и доступность
- Нажмите Alt+1 для режима чтения с экрана.
- Нажмите Alt+0 для отмены.
- Используйте сайт в режиме чтения с экрана.
- Руководство по доступности для чтения с экрана, отзывов и сообщений о проблемах.
- Перейти к содержимому.
## Магазин
Cosmos Sport
- 22 594000
- Заработайте x10 CASHBACK при первой покупке GoX.
- EASY RETURN
- FIND YOUR ORDER
- «GOX» и найдите предложение.
- Новинки
- Мужчины
- Женщины
- Дети
- Аксессуары
- Спорт
- Бренды
- Распродажи
- Подарочная карта
## Предложения
- Новинки. Только что запущено.
- Не пропустите: заработайте до 20 € в GoX Wallet.
- Не пропустите: On Shoes.
- Откройте коллекцию.
- Чёрные кроссовки.
- Исследуйте коллекцию.
## Товар
**Главная → Спорт → Лыжи → adidas Terrex Anylander Unisex Trail Shoes**
- Посмотреть похожие товары
- Бренд: adidas Terrex
- Название: adidas Terrex Anylander Unisex Trail Shoes
- Цена: €75.00
- Артикул: `9000198351_63596`
- Размер: выберите размер
- Таблица размеров
- Добавить в корзину
- Оформить заказ
- Наличие в магазинах
- Самовывоз из магазина за 2 часа
- Выберите цвет
Участник GoX получает 0,75 € кэшбэка. Зарегистрироваться.
- Бесплатный возврат в течение 30 дней.
- BOX NOW Lockers: быстрая доставка 24/7.
- Ваш заказ уже в пути!
- История цен.
## Характеристики
- Вес: 390 г.
- Перепад: 10 мм.
## Описание
### Уход за обувью
### Доставка и возврат
Бесплатная доставка и возврат.
### Описание товара
adidas Terrex Anylander Unisex Trail Shoes предназначены для коротких прогулок по лесу и длительных однодневных походов. Эти туристические кроссовки adidas Terrex обеспечивают поддержку и комфорт на разных типах троп. Низкая посадка и мягкая амортизация в межподошве обеспечивают лёгкость и комфорт. Рельефная подошва Traxion даёт сцепление во всех направлениях и помогает уверенно держаться на поверхности.
Используя переработанные материалы, adidas повторно применяет уже созданные материалы и сокращает отходы. Возобновляемые материалы помогают снизить зависимость от ограниченных ресурсов. В моделях, изготовленных из смеси переработанных и возобновляемых материалов, их общая доля составляет не менее 20%.
### Особенности
- Обычная посадка.
- Шнуровка.
- Текстильный верх с усиленным носком.
- Текстильная подкладка.
- Межподошва EVA.
- Подошва Traxion.
- Вес: 390 г, размер UK 8,5.
- Перепад межподошвы: 10 мм; пятка 27 мм, носок 17 мм.
- Содержит не менее 20% переработанных и возобновляемых материалов.
- Цвет: чёрный.
- SKU: `ID0895`
- Артикул: `9000198351_63596`
## О бренде adidas Terrex
adidas Terrex — линейка экипировки adidas для пешего туризма, альпинизма, трейлраннинга и скалолазания. Она является значимым продолжением спортивных традиций adidas и предлагает инновационные решения для активного отдыха.
Линейка была запущена в конце 2000-х годов, когда adidas заметила растущую потребность в прочной и высокопроизводительной экипировке. Ассортимент Terrex значительно расширился и включает товары, отличающиеся долговечностью, технологичностью и функциональностью.
Terrex уделяет внимание устойчивому развитию и стремится уменьшить воздействие на окружающую среду. Бренд использует переработанные материалы и внедряет экологичные производственные практики.
adidas Terrex продолжает развиваться и внедрять инновации, помогая спортсменам расширять свои возможности и безопасно наслаждаться природой.
## Связанные категории
- Купить больше: мужская одежда, обувь и аксессуары.
- Купить больше: женская коллекция.
- Купить больше: мужская одежда, обувь и аксессуары → мужская обувь.
- Купить больше: женская коллекция → женская обувь.
- Купить больше: модели для трейлраннинга и походов.
- Купить больше: мужская одежда, обувь и аксессуары adidas.
- Купить больше: мужская одежда, обувь и аксессуары → мужская обувь → мужские трейловые кроссовки.
## Сервисы Cosmos Sport
- Электронная подарочная карта: подарки на выбор среди более чем 20 000 моделей; номинал от 20 €.
- Бесплатный Click & Collect в 40 пунктах.
- BOX NOW: бесплатная доставка в постаматы.
- GoX Cashback: 1% кэшбэка в GoX Wallet для следующих покупок.
- Магазин для спортсменов: более 60 брендов.
- Бесплатный Click & Collect в более чем 40 пунктах.
- Бесплатный возврат в течение 30 дней.
- Бесплатная доставка при заказе от 49 €.
## Новости и контакты
CosmosNews: получайте первые предложения со скидками до 30% и эксклюзивные предложения.
Интересует:
- Мужчина.
- Женщина.
- Ребёнок.
Популярные ссылки:
- Найти заказ.
- Бесплатная доставка при заказе от 49 €.
- Бесплатный возврат в течение 30 дней.
- Контакты.
- 22 594000.
- Часы работы колл-центра: понедельник–пятница 09:00–21:00, суббота 09:00–20:00.
## Компания
- Магазины и пункты выдачи.
- Наша история.
- GoX.
- Условия и положения.
- Вакансии.
- Условия использования.
- Корпоративная социальная ответственность.
- Cosmos People.
- Льготы.
- TEAM WEAR.
## Обслуживание
- Служба поддержки.
- Контакты.
- Бесплатный возврат любым способом.
- Доставка и получение товаров.
- Способы оплаты.
- Бесплатный Click & Collect.
- Часто задаваемые вопросы.
- Подарочные карты и электронные подарочные карты.
- Политика конфиденциальности.
- Файлы cookie.
- Декларация о файлах cookie.
Присоединяйтесь: Facebook, Instagram.
© 2026 CosmosSport. Разработано Sleed.
+65 -14
View File
@@ -9,18 +9,21 @@ reflected here FIRST, then implemented in `gen_v2` and other tools.
## Core Principles
1) YAML per service is generated ONLY from API data.
2) The provider core is universal and must not contain service-specific logic.
3) Service-specific Go code is fully generated from YAML. No manual edits.
2) The provider core (`provider/internal/core`) is universal and must not contain
service-specific logic.
3) Service-specific Go code (`provider/internal/resources_gen`) is fully generated
from YAML. No manual edits to generated code.
4) Documentation is generated from the same YAML.
5) Build artifacts for 3 OS targets are published to the registry, and docs are
published to the website.
6) `devops/ARCHITECTURE.md` (this file) is the primary spec. Code follows.
6) `TOOLS/ARCHITECTURE.md` (this file) is the primary spec. Code follows.
## Service Selection
- The inclusion list is defined by: `devops/config/services_list.txt` (repo-relative path)
- The inclusion list is defined by: `TOOLS/config/<dev|test|prod>/services_list.txt`
(по одному списку на стенд; repo-relative path)
- Each line starts with service_id, followed by service name/alias.
- Operation timeouts source is defined by: `devops/config/operation_timeouts.json`.
- Operation timeouts source is defined by: `TOOLS/config/<dev|test|prod>/operation_timeouts.json`.
## API Endpoint
@@ -106,18 +109,38 @@ Each operation has a kind:
## Provider Model
- Core is universal: no service-specific logic inside the core.
- Generated service resources contain only schema/params and references.
- Core (`provider/internal/core`) is universal: no service-specific logic inside it.
- Generated service resources (`provider/internal/resources_gen`) contain only
schema/params and references.
- Hand-written service resources live in `provider/internal/resources_core` and are
registered in `provider/internal/provider/provider.go` `Resources()`. They are
NOT generated; they must not contain arbitrary service logic, only:
- a schema, and
- wiring between schema fields and the universal core API
(`RunInstanceOperationUniversalByCode`, `ResolveRefSvcParamValue`, etc.).
### API Resilience
- Core MUST retry transient 401 errors from Gateway (3 attempts, exponential backoff).
Gateway may temporarily reject valid JWT tokens.
- GET operations (GetInstanceState, GetInstanceStateRaw) retry 401 with 2s/4s/8s backoff.
- `doRequest` treats 401 as retryable for GET requests (alongside 429, 502, 503, 504).
- Implemented: `isRetryable` (`core/http.go`) includes 401 alongside {429, 502, 503, 504};
retry applies to GET requests only.
- Network errors and HTTP retryable statuses are retried for **GET only** (decided
2026-09-30). POST is NEVER retried: `POST /instanceOperations` is not idempotent, and a
blind retry would duplicate the operation. The API has no `Idempotency-Key` support.
- Draft operation on failure (decided 2026-09-30): if an error occurs after
`POST /instanceOperations` but before `.../run`, the operation remains created but was
never executed (the instance is unaffected). No cancellation API is known — neither the
provider code nor the captured HAR contain `DELETE /instanceOperations/{uid}`.
- Transient 401 is retried for GET (see `isRetryable`).
### Generated Code Resilience
- **Partial state при ошибке create** (шаблон `instance.go`): если инстанс успел создаться
(`CreateResourceWithTimeout` вернул непустой uid ВМЕСТЕ с ошибкой), uid фиксируется в state
до вывода ошибки. Иначе облачный инстанс «осиротеет»: Terraform о нём не знает, а повторный
`apply` упрётся в страж дубликатов. Ядро (`CreateGenericInstanceUniversalV6`) возвращает uid
при любой ошибке ПОСЛЕ создания инстанса; пустой uid — только если создание не состоялось.
- **Zero-value fallback** (`normalizeUniversalValueV6`): если параметр отсутствует
в пользовательском `.tf`, подставлять zero-value по `dataType`:
- `integer` → `"0"`, `boolean` → `"false"`, `map-fixed` → `"{}"`, `array` → `"[]"`, `string` → `""`
@@ -207,28 +230,56 @@ From the unified YAML, generate:
5) Upload provider artifacts to registry.
6) Build and publish docs to site.
## Lifecycle Vocabulary (single contract)
**Target contract (decided 2026-09-30):** two runtime flags — `keep_on_destroy` and
`suspend_on_destroy` — express destroy behaviour for ALL resource kinds. The compile-time
`delete_strategy` (YAML) is a generator input that MAPS onto them:
| `delete_strategy` | Runtime meaning |
|---|---|
| `noop_warn` | `keep_on_destroy = true` (platform effect left untouched) |
| `inverse` | normal destroy (inverse modify is performed) |
| `error` | destroy refused with a validation error |
Current state (2026-09-30) — to be migrated:
1. generated instance resources: already `suspend_on_destroy` / `keep_on_destroy`;
2. generated modifiers: `delete_strategy` only (no runtime flag yet);
3. hand-written modifiers: already `keep_on_destroy`.
## Non-Negotiable Rules
- No manual edits to generated YAML or generated Go code.
- Any change must come from API or generator logic updates.
- Any change to generated code 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:
services. Service-specific deviations are of two kinds:
1. **Generated modifiers** — a `modify` op that should become a dedicated modifier
resource. The generator (`TOOLS/resource-generator/internal/loader/loader.go`)
supports `kind: modifier` with `delete_strategy` (`noop_warn`/`inverse`/`error`)
and `idempotency` (`none`/`check_before_run`). The overlay data file
`modifiers.yaml` that would drive this is **documented but NOT yet created**;
until then, modifiers are hand-written in `provider/internal/resources_core/`.
(The legacy registry `serviceSpecificModifiers` in `TOOLS/yaml-generator/main.go`
was REMOVED during the 2026-09-23 refactoring.)
2. **Doc examples** — named registry:
| 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.
- Doc-example exception = editing the named registry above → visible in diff.
- Modifier exception (until `modifiers.yaml` exists) = hand-written resource in
`provider/internal/resources_core/` + registration in `provider.go`.
- Never annotate API-YAML: it is machine-regenerated and edits would be lost.
Enforced by scripts (run before build/commit):
+39
View File
@@ -2,6 +2,45 @@
Каждый инструмент — независимый Go-модуль.
## Канонический пайплайн (порядок шагов)
```bash
# 1) YAML-спеки сервисов из API (per-stand!)
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev
# 2) Go-ресурсы + документация из этих YAML
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev
# 3) (релиз) сборка 3 платформ + публикация в реестр
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev
```
`--profile` обязателен: без него скрипты выходят с кодом 2 (никаких дефолтов).
### Шаг 1 — безопасная генерация YAML
`01_generate_yamls.sh` работает по принципу «сначала во временное, потом атомарная замена»:
- генерация идёт в staging-каталог `generated/<stand>/resources_yaml.staging.<pid>/`;
- рабочий `generated/<stand>/resources_yaml/` **не** удаляется и **не** модифицируется до полного успеха;
- при полном успехе старый каталог уезжает в бэкап `resources_yaml.bak-<UTC>`,
а staging встаёт на его место (атомарный `mv` в пределах одного FS),
хранятся последние `KEEP_BACKUPS` (по умолчанию 5);
- при любой ошибке замена **отменяется**: старый каталог цел, частичный результат лежит в staging для разбора, скрипт выходит с кодом 1;
- в каталоге лежит маркер `.stand`, защищающий от генерации не в тот стенд.
Перегенерировать все стенды подряд:
```bash
for s in dev test prod; do
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/$s || break
done
```
> Примечание: часть параметров API отдаёт со случайным `default`-суффиксом
> (`db-ievgpdvu` → `db-ujama5rb` и т.п.), поэтому побайтовое сравнение двух
> прогонов даёт различия в этих строках — это не регрессия.
## yaml-generator
API Nubes → `resources_yaml/*.yaml`
+1 -1
View File
@@ -4,7 +4,7 @@ TOKEN_FILE="secrets/dev.token"
# Release versions
# Version
VERSION="2.0.17"
VERSION="2.0.0"
NAMESPACE="nubes-dev"
PROVIDER_NAME="nubes"
+1 -1
View File
@@ -49,7 +49,7 @@
148 vcMgmtSthutrvalCluster # Менеджмент Kubernetes кластер Штурвал
149 valoTenant # VALO Cloud
150 k8sSthutrvalCluster # Kubernetes кластер Штурвал
# 151 k8sOpenbao # Vault — нет в PROD UI
151 k8sOpenbao # Vault
# 153 nifi # Nifi (DEV)
163 llmAi # LLM
# 175 k8sGo # Go — нет в UI
-55
View File
@@ -1,55 +0,0 @@
# Общий список сервисов — объединение DEV/TEST/PROD
# service_id service_name # RU description
# do not delete lines; to exclude a service, comment it with #
1 dummy # Болванка
2 template # Темплейт k8s
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)
28 vc_vm_v3 # Виртуальная машина
29 vcVdcGroup # Группа датацентров
# 32 vmpostgre # vc_vm_postgresql_std
50 nextcloud # Nextcloud
81 superset # Apache Superset
82 harbor # Container Registry
86 k8sVelero # Velero
# 87 k8svalkey # Valkey — нет в UI
# 88 k8sZitiController # k8sZitiController — нет в UI
89 flask # Web-сервер с фреймворком Flask
90 postgres # PostgreSQL
91 redis # Redis
92 mongodb # MongoDB
93 rabbitmq # RabbitMQ
94 lucee # Lucee
95 nodejs # NodeJS
96 pgadmin # pgAdmin
# 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
119 Akhq # Akhq
120 clickhouse # ClickHouse
148 vcMgmtSthutrvalCluster # Менеджмент Kubernetes кластер Штурвал
149 valoTenant # VALO Cloud
150 k8sSthutrvalCluster # Kubernetes кластер Штурвал
151 k8sOpenbao # Vault
# 153 nifi # Nifi (DEV)
163 llmAi # LLM
# 175 k8sGo # Go — нет в UI
@@ -313,6 +313,12 @@ func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.Cre
}
id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout)
if err != nil {
// Partial state (Q5): фиксируем ТОЛЬКО id. Остальные атрибуты плана могут быть
// unknown/computed — запись их в state даёт "invalid new value ... unknown" и
// маскирует исходную ошибку. Read затем синхронизирует реальное состояние.
if id != "" {
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(id))...)
}
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
+84 -84
View File
@@ -43,10 +43,9 @@ resolve_root_path() {
echo "${ROOT_DIR}/${path_value}"
}
# Список сервисов — только из профиля стенда (TOOLS/config/<stand>/services_list.txt).
# Общий TOOLS/config/services_list.txt удалён 2026-09-30 (не использовался ни одним профилем).
SERVICES_FILE_DEFAULT="${PROFILE_DIR}/services_list.txt"
if [[ -f "${PROFILE_DIR}/services_list.txt" ]]; then
SERVICES_FILE_DEFAULT="${PROFILE_DIR}/services_list.txt"
fi
SERVICES_FILE="${SERVICES_FILE:-$SERVICES_FILE_DEFAULT}"
SERVICES_FILE="$(resolve_root_path "$SERVICES_FILE")"
@@ -54,7 +53,7 @@ SERVICES_FILE="$(resolve_root_path "$SERVICES_FILE")"
REQUEST_DELAY="${REQUEST_DELAY:-0.5}"
ATTEMPTS="${ATTEMPTS:-3}"
# Auth: either an explicit token or a token file (latest *.token if not set).
# Auth: токен из NUBES_API_TOKEN или из файла TOKEN_FILE (profile.env).
TOKEN_FILE="${TOKEN_FILE:-}"
NUBES_API_TOKEN="${NUBES_API_TOKEN:-}"
if [[ -n "$TOKEN_FILE" ]]; then
@@ -62,37 +61,39 @@ if [[ -n "$TOKEN_FILE" ]]; then
fi
# Назначение:
# - Берет список сервисов из services_list.txt.
# - Берет список сервисов из services_list.txt профиля стенда.
# - Для каждого сервиса запрашивает единый spec через API и пишет YAML в
# ${ROOT_DIR}/provider/resources_yaml.
# ${ROOT_DIR}/generated/<stand>/resources_yaml (per-stand, см. ниже).
# - Формат имени файла: ID_имя.yaml (например, 13_s3bucket.yaml).
#
# Безопасность записи (best practice, с 2026-09-30):
# - Генерация идёт во временный каталог (staging), старый каталог не удаляется заранее.
# - Замена каталога атомарна и делается ТОЛЬКО при полном успехе; предыдущий
# каталог уезжает в бэкап resources_yaml.bak-<UTC>, хранятся последние KEEP_BACKUPS.
# - При любом сбое рабочий каталог остаётся нетронутым, частичный результат — в staging.
#
# Источники данных:
# - Список сервисов: TOOLS/config/{stand}/services_list.txt
# - API endpoint: ${API_ENDPOINT}
# - Токен: переменная NUBES_API_TOKEN или файл .token.
# - API endpoint: ${API_ENDPOINT} (обязателен, только из profile.env)
# - Токен: переменная NUBES_API_TOKEN или файл TOKEN_FILE (из profile.env).
#
# Формат services_list.txt:
# - В начале строки: service_id.
# - Остальное в строке — произвольная заметка для девопса (не используется).
# - Поле 1: service_id (обязательно).
# - Поле 2: имя сервиса (используется для лога; можно опустить).
# - Всё после # — заметка для девопса (не используется).
#
# Задержки и попытки:
# - REQUEST_DELAY (секунды) между запросами и ретраями (по умолчанию 0.2).
# - REQUEST_DELAY (секунды) между запросами и ретраями (по умолчанию 0.5).
# - ATTEMPTS — число попыток на сервис (по умолчанию 3).
#
# Логи ошибок:
# - Список неуспешных сервисов пишется в /tmp/yaml_gen_failures.txt.
# - Список неуспешных сервисов пишется в generated/<stand>/tmp/yaml_gen_failures.txt.
#
# Токен берется из переменной NUBES_API_TOKEN.
# Если переменная пуста, берется файл .token из ${ROOT_DIR}:
# - "последний" = файл с самым новым временем изменения (ls -t | head -n 1).
if [[ -z "$NUBES_API_TOKEN" ]]; then
if [[ -z "$TOKEN_FILE" ]]; then
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
fi
if [[ -n "$TOKEN_FILE" && -f "$TOKEN_FILE" ]]; then
NUBES_API_TOKEN=$(cat "$TOKEN_FILE")
fi
# Токен: NUBES_API_TOKEN, иначе файл TOKEN_FILE (из profile.env).
# Легаси-поиск «последнего» *.token в корне репо удален (2026-09-30): токенов там
# нет, а такой поиск мог молча подхватить чужой токен.
if [[ -z "$NUBES_API_TOKEN" && -n "$TOKEN_FILE" && -f "$TOKEN_FILE" ]]; then
NUBES_API_TOKEN=$(cat "$TOKEN_FILE")
fi
if [[ -z "$NUBES_API_TOKEN" ]]; then
@@ -105,12 +106,18 @@ if [[ ! -f "$SERVICES_FILE" ]]; then
exit 2
fi
# ⛔ LEGACY: deck-api.ngcloud.ru ЗАКРЫВАЕТСЯ. Default = Gateway. Override via NUBES_API_ENDPOINT.
API_ENDPOINT="${NUBES_API_ENDPOINT:-https://lk-api-gateway.ngcloud.ru/api/v1/svc}"
# Endpoint ОБЯЗАТЕЛЕН: берется ТОЛЬКО из NUBES_API_ENDPOINT (profile.env).
# Легаси-фолбэк на PROD gateway удален (2026-09-30): молчаливый уход в прод недопустим.
if [[ -z "${NUBES_API_ENDPOINT:-}" ]]; then
echo "Error: NUBES_API_ENDPOINT is required (set it in ${PROFILE_DIR}/profile.env)." >&2
exit 2
fi
API_ENDPOINT="$NUBES_API_ENDPOINT"
# Auto-detect API style: if endpoint contains "index.cfm" → legacy proxy (?endpoint=),
# otherwise → new REST gateway (direct paths). No forced /index.cfm normalization.
GENERATED_DIR="${ROOT_DIR}/generated/$(basename "$PROFILE_DIR")"
STAND="$(basename "$PROFILE_DIR")"
GENERATED_DIR="${ROOT_DIR}/generated/${STAND}"
YAML_OUTPUT_DIR_DEFAULT="${GENERATED_DIR}/resources_yaml"
FAILURES_FILE_DEFAULT="${GENERATED_DIR}/tmp/yaml_gen_failures.txt"
@@ -119,12 +126,27 @@ FAILURES_FILE="${FAILURES_FILE:-$FAILURES_FILE_DEFAULT}"
YAML_OUTPUT_DIR="$(resolve_root_path "$YAML_OUTPUT_DIR")"
FAILURES_FILE="$(resolve_root_path "$FAILURES_FILE")"
# Убедимся, что папка есть. Полной очистки нет: обрабатываем только сервисы из списка.
mkdir -p "$YAML_OUTPUT_DIR"
YAML_PARENT_DIR="$(dirname "$YAML_OUTPUT_DIR")"
YAML_BASE_NAME="$(basename "$YAML_OUTPUT_DIR")"
mkdir -p "$YAML_PARENT_DIR"
mkdir -p "$(dirname "$FAILURES_FILE")"
if [[ ! -f "${YAML_OUTPUT_DIR}/embed.go" ]]; then
cat > "${YAML_OUTPUT_DIR}/embed.go" <<'EOF'
# Защита от генерации не в тот стенд: каталог помечается маркером .stand.
if [[ -d "$YAML_OUTPUT_DIR" && -f "${YAML_OUTPUT_DIR}/.stand" ]]; then
existing_stand="$(cat "${YAML_OUTPUT_DIR}/.stand" 2>/dev/null || true)"
if [[ -n "$existing_stand" && "$existing_stand" != "$STAND" ]]; then
echo "Error: ${YAML_OUTPUT_DIR} belongs to stand '${existing_stand}', not '${STAND}'. Refusing." >&2
exit 2
fi
fi
# Staging: старый каталог НЕ удаляется заранее, замена — атомарная (mv в конце).
STAGING_DIR="${YAML_OUTPUT_DIR}.staging.$$"
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
# embed.go обязателен: пакет resources_yaml используется через go:embed *.yaml.
cat > "${STAGING_DIR}/embed.go" <<'EOF'
package resources_yaml
import "embed"
@@ -134,7 +156,7 @@ import "embed"
//go:embed *.yaml
var Files embed.FS
EOF
fi
printf '%s\n' "$STAND" > "${STAGING_DIR}/.stand"
rm -f "$FAILURES_FILE"
@@ -164,7 +186,12 @@ while IFS= read -r line; do
continue
fi
sid=$(echo "$line" | awk '{print $1}')
svc_name=""
# Имя сервиса берётся из 2-го поля services_list.txt. API ради имени не дёргаем:
# Go-генератор сам запрашивает spec и нормализует имя (это убирает лишний запрос).
svc_name="$(echo "$line" | awk '{print $2}')"
if [[ "$svc_name" == \#* ]]; then
svc_name=""
fi
if [[ -z "$sid" ]]; then
continue
@@ -176,59 +203,13 @@ while IFS= read -r line; do
continue
fi
# Если имя не указано в списке — подтягиваем по API.
if [[ -z "$svc_name" ]]; then
svc_name=$(python3 - <<PY
import json, sys, time, urllib.parse, urllib.request
sid = "${sid}"
endpoint = "${API_ENDPOINT}"
token = "${NUBES_API_TOKEN}"
max_retries = 3
# Auto-detect API style: "index.cfm" → legacy proxy, otherwise → REST gateway.
if "index.cfm" in endpoint:
params = urllib.parse.urlencode({"endpoint": f"/services/{sid}"})
url = f"{endpoint}?{params}"
else:
url = f"{endpoint}/services/{sid}"
for attempt in range(1, max_retries + 1):
try:
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Bearer {token}")
req.add_header("User-Agent", "Mozilla/5.0 (compatible; Terraform-Provider-Nubes/BashGenerator)")
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
svc = data.get("svc", {})
name = svc.get("svcShort") or svc.get("name") or svc.get("title") or f"service_{sid}"
print(name)
sys.exit(0)
except Exception as e:
if attempt < max_retries:
time.sleep(2 * attempt)
else:
print(f"ERROR: failed to fetch service {sid} after {max_retries} attempts: {e}", file=sys.stderr)
sys.exit(1)
PY
)
if [[ "$svc_name" == ERROR:* ]]; then
echo "$svc_name" >&2
echo "${sid} api_error" >> "$FAILURES_FILE"
continue
fi
fi
# Один YAML на сервис формируется Go-генератором.
# Один YAML на сервис формируется Go-генератором — пишем во временный каталог.
echo "Generating unified spec for ${sid} (${svc_name})"
success=0
# Generator normalizes names, so verify output by ID prefix only.
output_glob="${YAML_OUTPUT_DIR}/${sid}_*.yaml"
# Для выбранного сервиса удаляем старый YAML и генерируем заново с ретраями.
rm -f "$output_glob"
output_glob="${STAGING_DIR}/${sid}_*.yaml"
# Старый YAML НЕ удаляем заранее: полная замена каталога — атомарная, в конце.
# Генерация YAML для выбранного сервиса с ретраями.
for attempt in $(seq 1 "$ATTEMPTS"); do
(
@@ -237,7 +218,7 @@ PY
NUBES_SERVICE_ID="$sid" \
NUBES_SERVICE_NAME="$svc_name" \
NUBES_API_ENDPOINT="$API_ENDPOINT" \
NUBES_OUTPUT_DIR="$YAML_OUTPUT_DIR" \
NUBES_OUTPUT_DIR="$STAGING_DIR" \
"${ROOT_DIR}/TOOLS/bin/yaml-generator"
) && success=1 || success=0
@@ -265,9 +246,28 @@ PY
done < "$SERVICES_FILE"
echo "Done. YAML files are in ${YAML_OUTPUT_DIR}"
if [[ -f "$FAILURES_FILE" ]]; then
if [[ -s "$FAILURES_FILE" ]]; then
echo "Failed services (see $FAILURES_FILE):" >&2
cat "$FAILURES_FILE" >&2
echo "" >&2
echo "Замена каталога ОТМЕНЕНА: рабочий ${YAML_OUTPUT_DIR} не тронут." >&2
echo "Частичный результат оставлен для разбора: $STAGING_DIR" >&2
exit 1
fi
fi
# Атомарная замена: старый каталог уезжает в бэкап, новый встаёт на его место.
BACKUP_DIR="${YAML_PARENT_DIR}/${YAML_BASE_NAME}.bak-$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -d "$YAML_OUTPUT_DIR" ]]; then
mv "$YAML_OUTPUT_DIR" "$BACKUP_DIR"
echo "Backup of previous specs: $BACKUP_DIR"
fi
mv "$STAGING_DIR" "$YAML_OUTPUT_DIR"
# Ротация бэкапов: держим последние KEEP_BACKUPS (по умолчанию 5).
KEEP_BACKUPS="${KEEP_BACKUPS:-5}"
mapfile -t stale_backups < <(ls -1dt "${YAML_PARENT_DIR}/${YAML_BASE_NAME}.bak-"* 2>/dev/null | tail -n +"$((KEEP_BACKUPS + 1))")
if [[ "${#stale_backups[@]}" -gt 0 ]]; then
rm -rf -- "${stale_backups[@]}"
fi
echo "Done. YAML files are in ${YAML_OUTPUT_DIR}"
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Generate docs using ClickHouse template (v2, no color markup).
# Canonical docs generator for unified resources_yaml.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
PROVIDER_DIR="${ROOT_DIR}/provider"
RESOURCES_YAML_DIR="${PROVIDER_DIR}/resources_yaml"
DOCS_DIR="${ROOT_DIR}/docs/30_registry/resources"
SERVICES_LIST_PATH="${PROFILE_DIR}/services_list.txt"
cd "$PROVIDER_DIR"
${ROOT_DIR}/TOOLS/bin/docs-generator \
-resources "$RESOURCES_YAML_DIR" \
-docs "$DOCS_DIR" \
-services "$SERVICES_LIST_PATH" \
-exclude "clickhouse"
echo "Docs generated in ${DOCS_DIR}"
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
SCRIPT="${ROOT_DIR}/TOOLS/scripts/01_generate_yamls.sh"
TOKEN_FILE="${TOKEN_FILE:-}"
REQUEST_DELAY="${REQUEST_DELAY:-0.2}"
ATTEMPTS="${ATTEMPTS:-3}"
RUNS="${RUNS:-10}"
LOG="${LOG:-/tmp/yaml_gen_runs.log}"
export TOKEN_FILE
if [[ -z "$TOKEN_FILE" ]]; then
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
fi
if [[ -z "$TOKEN_FILE" || ! -f "$TOKEN_FILE" ]]; then
echo "Error: token file not found in ${ROOT_DIR}" >&2
exit 2
fi
: > "$LOG"
echo "Runs: $RUNS" | tee -a "$LOG"
echo "Request delay: $REQUEST_DELAY" | tee -a "$LOG"
echo "Attempts per service: $ATTEMPTS" | tee -a "$LOG"
echo "Token file: $TOKEN_FILE" | tee -a "$LOG"
echo "" | tee -a "$LOG"
for i in $(seq 1 "$RUNS"); do
start=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "RUN $i start $start" | tee -a "$LOG"
REQUEST_DELAY="$REQUEST_DELAY" ATTEMPTS="$ATTEMPTS" "$SCRIPT" >> "$LOG" 2>&1
rc=$?
end=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "RUN $i end $end rc=$rc" | tee -a "$LOG"
echo "" | tee -a "$LOG"
sleep 0.5
done
echo "Log: $LOG" | tee -a "$LOG"
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
TOKEN_FILE="${TOKEN_FILE:-}"
if [[ -z "$TOKEN_FILE" ]]; then
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
fi
if [[ -z "$TOKEN_FILE" || ! -f "$TOKEN_FILE" ]]; then
echo "Error: token file not found in ${ROOT_DIR}" >&2
exit 2
fi
export TOKEN_FILE
exec "${ROOT_DIR}/TOOLS/scripts/10_yaml_stability_run.sh"
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
TOKEN_FILE="${TOKEN_FILE:-}"
if [[ -z "$TOKEN_FILE" ]]; then
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
fi
if [[ -z "$TOKEN_FILE" || ! -f "$TOKEN_FILE" ]]; then
echo "Error: token file not found in ${ROOT_DIR}" >&2
exit 2
fi
export TOKEN_FILE
exec "${ROOT_DIR}/TOOLS/scripts/01_generate_yamls.sh"
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
PROVIDER_DIR="${ROOT_DIR}/provider"
if [[ ! -d "${PROVIDER_DIR}/resources_yaml" ]]; then
echo "Error: resources_yaml not found: ${PROVIDER_DIR}/resources_yaml" >&2
exit 2
fi
# Start from a clean slate to avoid stale YAMLs.
rm -f "${PROVIDER_DIR}/resources_yaml"/*.yaml
exec "${ROOT_DIR}/TOOLS/scripts/12_generate_yamls_latest.sh"
+28 -10
View File
@@ -3,27 +3,45 @@ set -euo pipefail
# check_hardcoded_service_ids.sh — запрет сервис-специфичных хардкодов по числовому ID.
#
# Ищет сравнения вида svc.ID == N / ServiceID == N / spec.ServiceID == N (N > 0)
# в Go-коде TOOLS/. Исключения должны жить ТОЛЬКО в именованных реестрах (данные):
# - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)
# Ищет:
# 1) сравнения вида svc.ID == N / ServiceID == N / spec.ServiceID == N (N > 0)
# 2) литеральные ref-service id в вызовах резолва:
# ResolveRefSvcParamValue(ctx, 19, ...) / ResolveRefSvcParamDisplayName(ctx, 22, ...)
#
# Область: TOOLS/** и provider/internal/** (кроме сгенерированного resources_gen/**).
# Исключения должны жить в именованных реестрах/константах (данные), а не как магические числа.
# - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)
# - provider/internal/resources_core/*.go (именованные svcID* константы)
#
# Выход: 0 — хардкодов нет; 1 — найдены.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
matches="$(grep -rnE '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' "$ROOT_DIR/TOOLS" --include='*.go' || true)"
SCAN_DIRS=("$ROOT_DIR/TOOLS" "$ROOT_DIR/provider/internal")
matches=""
for dir in "${SCAN_DIRS[@]}"; do
[[ -d "$dir" ]] || continue
# Сгенерированный код не сканируем — он не редактируется руками (см. ARCHITECTURE.md).
found="$(grep -rnE \
-e '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' \
-e '(ResolveRefSvcParamValue|ResolveRefSvcParamDisplayName)\([^,]+, *[1-9][0-9]*' \
"$dir" --include='*.go' --exclude-dir=resources_gen || true)"
if [[ -n "$found" ]]; then
matches+="$found"$'\n'
fi
done
if [[ -n "$matches" ]]; then
echo "HARDCODED SERVICE ID FOUND (service-specific logic must live in a registry):" >&2
echo "$matches" >&2
echo "HARDCODED SERVICE ID FOUND (service-specific logic must live in a named registry):" >&2
printf '%s' "$matches" >&2
echo "" >&2
echo "Вынеси исключение в один из реестров:" >&2
echo " - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)" >&2
echo "Вынеси исключение в именованный реестр/константу:" >&2
echo " - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)" >&2
echo "См. TOOLS/ARCHITECTURE.md, раздел «Реестр исключений»." >&2
echo " - provider/internal/resources_core/*.go (именованные svcID* константы)" >&2
echo "См. TOOLS/ARCHITECTURE.md, раздел «Exception Registry»." >&2
exit 1
fi
echo "OK: no hardcoded service IDs in TOOLS/."
echo "OK: no hardcoded service IDs in TOOLS/ and provider/internal/."
+12 -54
View File
@@ -30,7 +30,12 @@ type ServiceRef struct {
// Load читает конфигурацию из переменных окружения и файлов.
func Load() (Config, error) {
apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://lk-api-gateway.ngcloud.ru/api/v1/svc")
// Endpoint ОБЯЗАТЕЛЕН. Легаси-дефолт на PROD gateway удален (2026-09-30):
// провайдер не должен молча ходить в прод при незаданном endpoint.
apiEndpoint := strings.TrimSpace(os.Getenv("NUBES_API_ENDPOINT"))
if apiEndpoint == "" {
return Config{}, errors.New("NUBES_API_ENDPOINT is required (no default: refusing to guess a stand endpoint)")
}
apiEndpoint = normalize.APIEndpoint(apiEndpoint)
apiToken, err := loadToken()
if err != nil {
@@ -51,13 +56,11 @@ func Load() (Config, error) {
services := []ServiceRef{}
if singleID == 0 {
// Без per-service ID нужен явный список. Угадывание пути удалено (2026-09-30):
// старый дефолт указывал на несуществующий provider/devops/config/services_list.txt.
listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE"))
if listPath == "" {
repoRoot, err := FindRepoRoot()
if err != nil {
return Config{}, err
}
listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt")
return Config{}, errors.New("NUBES_SERVICES_FILE is required when NUBES_SERVICE_ID is not set")
}
list, err := ReadServicesList(listPath)
if err != nil {
@@ -76,14 +79,6 @@ func Load() (Config, error) {
}, nil
}
func getenvDefault(key string, def string) string {
val := strings.TrimSpace(os.Getenv(key))
if val == "" {
return def
}
return val
}
func loadToken() (string, error) {
if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" {
return tok, nil
@@ -95,46 +90,9 @@ func loadToken() (string, error) {
}
return strings.TrimSpace(string(b)), nil
}
repoRoot, err := FindRepoRoot()
if err != nil {
return "", err
}
latest, err := findLatestToken(repoRoot)
if err != nil {
return "", err
}
if latest == "" {
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
}
b, err := os.ReadFile(latest)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
func findLatestToken(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var latest string
var latestTime int64
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
mt := info.ModTime().Unix()
if mt > latestTime {
latestTime = mt
latest = filepath.Join(dir, e.Name())
}
}
return latest, nil
// Легаси-фолбэк «найти последний *.token в корне репо» удален (2026-09-30):
// токенов там нет, а такой поиск мог молча подхватить чужой токен.
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
}
// ReadServicesList читает список сервисов из файла.
+9 -5
View File
@@ -2,11 +2,15 @@
> Обновлять после **каждой** заливки. Это единственный источник правды.
| Стенд | Namespace | Версия | Дата заливки |
|---|---|---|---|
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
| DEV | `nubes-dev` | `2.0.23` | 2026-09-24 | (fix: UUID внутри JSON нормализуется к lowercase при сравнении (adopt suspended-инстанса падал на регистре `nsxtUid`) + `JsonNormalize` приводит UUID-подстроки; тесты) |
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
| Стенд | Namespace | Версия | Дата заливки | Примечание |
|---|---|---|---|---|
| PROD | `nubes` | `1.0.0` | 2026-09-30 | **перезалит свежей сборкой** (перегенерация YAML + сборка из текущего кода) |
| DEV | `nubes-dev` | `2.0.0` | 2026-09-30 | **перезалит свежей сборкой**; в реестре также остаются `2.0.21`–`2.0.24` |
| TEST | `nubes-test` | `3.0.0` | 2026-09-30 | **перезалит свежей сборкой** |
> ⚠️ **Хранение в реестре (2026-09-30):** в dev-реестре (`nubes-dev/nubes`) оставлены только
> `2.0.21`–`2.0.24`; версии `2.0.0`–`2.0.20` удалены физически (бакет un-versioned).
> Подробности и проверки: `HISTORY/2026-09-30_dev_registry_prune_versions.md`.
## Как проверить
@@ -90,6 +90,16 @@ schema.StringAttribute{
## 4. Правильное решение
> ⚠️ **Историческая справка (пометка 2026-09-30).** Описанный ниже подход «Restore user-provided
> casing» (`originalVappUid` в шаблоне `instance.go`) в текущем коде **отсутствует**.
> Фактическая реализация регистра UUID:
> 1. ref_svc-атрибуты **исключены из read-back**: шаблон `instance.go` добавляет `InputField` только
> при `eq .RefSvcId 0`, поэтому в state остаётся регистр из config;
> 2. UUID-подстроки **внутри JSON** нормализуются при отправке
> (`resources_core.BuildJSON` → `jsonutil.LowercaseUUIDsInText`).
>
> Разделы §10–§11 ниже — актуальные. Раздел 4 оставлен как история разбора.
**Источник истины**: plan = config = "6214BA32-..." (регистр пользователя, неизменен)
**Задача**: state после apply тоже должен быть "6214BA32-..."
@@ -280,6 +290,8 @@ Error: required params mismatch for resource_name shturval-dev: startupConfigura
| 7 | `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`) | ref-параметр, зашитый внутрь JSON, **не проверяется вообще** (открыто) |
| 8 | `core/operation_run*.go` (`lookupLiveParam`) | подстановка live-значений по ключам: при другом регистре ключа может молча не сработать (открыто, требует живой проверки) |
> **Дополнение 2026-09-30**: пункты 9–10 (регистр UUID на **отправке** в API) — см. §11.
**Фикс (провайдер `2.0.23`, dev).**
- `internal/core/jsonutil/jsonutil.go`: добавлен `LowercaseUUIDsInText` (UUID-подстрока → lowercase), и строковые
@@ -295,3 +307,59 @@ Error: required params mismatch for resource_name shturval-dev: startupConfigura
значения); смотрите `jsonutil.LowercaseUUIDsInText`.
8. Не «лечите» это нормализацией плана целиком (скаляры → строки, сортировка ключей): для атрибутов из config
допустимо менять только регистр UUID-подстрок, иначе Terraform ругнётся на несоответствие плана конфигу.
9. Разделяйте **сравнение** и **отправку**: фиксы §10 закрывали только сравнение (план/state/adopt). Если ресурс
падает на create без видимых расхождений плана — смотрите путь отправки, §11.
---
## 11. Обновление 2026-09-30: UUID внутри JSON на ОТПРАВКЕ в API (create/modify/redeploy)
**Что уточнилось.** Все фиксы §10 закрывали **сравнение** (план vs state, adopt-suspended,
modifier-compare, диагностика) и **не** трогали то, что реально уходит в API при создании.
На провайдере `2.0.23` (то есть уже после §10) `create` кластера Штурвала **без** `lower(...)`
в HCL падал с «Edge не развёрнут в указанном vDC». Обнаружено при запуске Terraform
**из-под Windows**.
**Где именно.**
- Все map-fixed JSON-параметры (`startupConfiguration`, `clusterConfiguration`, …) собираются
одной функцией `resources_core.BuildJSON` (`provider/internal/resources_core/helpers.go`) —
она берёт значения подполей (`ValueString()`) **как есть**.
- Вызывает её сгенерированный код через `NestedJSONExpr` (`TOOLS/resource-generator/internal/templates/instance.go`,
ветки **Create** ~строка 302, **Modify** ~488, **Redeploy** ~505).
- `nubes_vc_nsxt` отдаёт `id` в UPPERCASE (`2C37FED1-…`), платформа хранит UUID в lowercase
и **сравнивает регистр при create** → UPPERCASE `nsxtUid`/`vdcUid` внутри JSON отвергается.
- `resolveRefSvcParamValues` (`core/refsvc.go`, `core/refsvc_resolve.go`) тут не спасает: он
нормализует только **top-level** refSvc-параметры и `s3.*uid` **внутри** map-fixed;
`vdcUid`/`nsxtUid` — обычные строковые подполя JSON, refSvcId у них нет.
**Аудит (продолжение таблицы §10, проверено 30.09).**
| # | Место | Что ломает |
|---|---|---|
| 9 | `resources_core/helpers.go` (`BuildJSON`) — **отправка** map-fixed JSON в API (create/modify/redeploy) | create падает: «Edge не развёрнут в указанном vDC» — регистр `vdcUid`/`nsxtUid` внутри JSON |
| 10 | `core/params.go` (`normalizeUniversalValueV6`) — дефолты create (`instance_create.go`) и досылка modify (`operation_run.go`) | скалярный UUID к lowercase не приводится (открыто, вторично) |
**Фикс (выпущен в dev `2.0.24`, 30.09.2026).**
- `provider/internal/resources_core/helpers.go`: `BuildJSON` оборачивает результат в
`jsonutil.LowercaseUUIDsInText(...)` (+ импорт `core/jsonutil`). Одна точка → покрыты **все**
map-fixed-параметры всех ресурсов на create / modify / redeploy. Регенерация не нужна.
**Почему это безопасно.**
- `BuildJSON` используется **только для отправки**, не для построения state (проверено: вызовы
только в `resources_gen/*.go`).
- Regex `uuidAnywhereRegex` (`[0-9a-f]{8}-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) совпадает только с UUID;
пароли/имена/иные строки не задевает. Внутри map-fixed строковых секретных полей
(password/secret/token) в спеках нет — только `*Uid`-ссылки на ресурсы.
- Это **выравнивание** с правилом «регистр UUID незначим» (то же приведение уже делается на
сравнении), а не новое поведение.
**Костыль в HCL.** `tf_examples/fullpipe_chain/shturval.tf:139-140`
(`vdc_uid = lower(...)`, `nsxt_uid = lower(...)`) после релиза `2.0.24` стал не нужен —
убирать отдельной командой (в HCL пока оставлен, чтобы не ломать провайдер `2.0.23`
на других стендах).
В локальных стендах `lower()` не используется: `DEV_STAND/FPipeGmail/shturval.tf:125-126` и
`DEV_STAND/FullPipe/shturval.tf1:123` передают `nubes_vc_vdc.vdc.id` / `nubes_vc_nsxt.edge.id`
напрямую → на провайдере `2.0.23` и ниже у них тот же риск, на `2.0.24` — уже нет.
+5
View File
@@ -48,6 +48,11 @@
- map/json → "{}"
- list/array → "[]"
- Пустые строки в JSON-параметрах запрещены (валидаторы на plan).
- UUID-подстроки в JSON (map-fixed) приводятся к lowercase **и при сравнении, и при отправке**
в API (`resources_core.BuildJSON` → `jsonutil.LowercaseUUIDsInText`). Платформа сравнивает
регистр UUID при create, а ресурсы могут отдавать `id` в UPPERCASE (например `nsxtUid`).
Поэтому `lower(...)` в конфигах стендов не нужен — см.
`docs/60_strategy/terraform_case_sensitivity_fix.md` §10–§11.
## 8. Soft Delete и карантин
- Для тяжёлых ресурсов delete часто заменён на suspend с периодом удержания.
+104 -1
View File
@@ -52,8 +52,111 @@ func TestNormalizeValue_TrimSpace(t *testing.T) {
}
}
// ===== waitForOperationFinish tests =====
func TestIsRetryable(t *testing.T) {
retryable := []int{
http.StatusUnauthorized, // 401 — транзиентный отказ Gateway
http.StatusTooManyRequests, // 429
http.StatusServiceUnavailable, // 503
http.StatusBadGateway, // 502
http.StatusGatewayTimeout, // 504
}
for _, code := range retryable {
if !isRetryable(code) {
t.Errorf("expected isRetryable(%d)=true", code)
}
}
notRetryable := []int{
http.StatusBadRequest, // 400
http.StatusForbidden, // 403
http.StatusNotFound, // 404
http.StatusInternalServerError, // 500
}
for _, code := range notRetryable {
if isRetryable(code) {
t.Errorf("expected isRetryable(%d)=false", code)
}
}
}
// Idempotent-пропуск НЕ должен создавать операцию: иначе останется «черновик»
// (POST /instanceOperations без run), и следующий modify будет ждать idle до таймаута.
func TestRunInstanceOperationByIdempotent_SkipsWithoutCreatingOperation(t *testing.T) {
var opPosts atomic.Int32
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/instances/inst-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"instance":{"instanceUid":"inst-1","serviceId":22,"explainedStatus":"running","isDeleted":false,"operationIsPending":false,"operationIsInProgress":false,"availableOperations":[{"svcOperationId":207,"operation":"modify"}],"state":{"params":{"ipSpaceName":"internet-ipv4-v1"}}}}`))
case r.Method == http.MethodGet && r.URL.Path == "/instanceOperations/default/207":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"svcOperation":{"cfsParams":[{"svcOperationCfsParamId":372,"code":"ipSpaceName","dataType":"string"}]}}`))
case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations":
opPosts.Add(1)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"instanceOperationUid":"op-x"}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
defer cleanup()
err := c.RunInstanceOperationUniversalByIdempotent(context.Background(), "inst-1", "modify",
map[string]string{"ipSpaceName": "internet-ipv4-v1"})
if err != nil {
t.Fatalf("expected nil error on idempotent skip, got: %v", err)
}
if n := opPosts.Load(); n != 0 {
t.Fatalf("operation must NOT be created when live already matches, got %d POST(s)", n)
}
}
// ===== Q5: partial-result tests (instance uid при ошибке после создания) =====
// Инстанс уже создан (POST /instances отдал Location), но следующая операция падает:
// uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет.
func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) {
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/instances":
w.Header().Set("Location", "./inst-9")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"instanceUid":"inst-9"}`))
case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"ERROR":"boom"}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
defer cleanup()
uid, err := c.CreateGenericInstanceUniversalV6(context.Background(), 21, "vm-1", nil)
if err == nil {
t.Fatal("expected error from failed operation")
}
if uid != "inst-9" {
t.Fatalf("expected instanceUid preserved on error, got %q", uid)
}
}
// Инстанс НЕ создан: uid обязан быть пустым (иначе запишем в state несуществующий ресурс).
func TestCreateGenericInstance_EmptyUIDWhenInstanceCreateFails(t *testing.T) {
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ERROR":"bad request"}`))
})
defer cleanup()
uid, err := c.CreateGenericInstanceUniversalV6(context.Background(), 21, "vm-1", nil)
if err == nil {
t.Fatal("expected error")
}
if uid != "" {
t.Fatalf("expected empty uid before creation, got %q", uid)
}
}
// ===== waitForOperationFinish tests =====
func makeTestClient(handler http.HandlerFunc) (*UniversalClient, func()) {
srv := httptest.NewServer(handler)
var buf bytes.Buffer
+6 -1
View File
@@ -117,8 +117,13 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa
}
// isRetryable returns true for transient HTTP errors that can be retried.
// 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md,
// «API Resilience»). Токен между попытками НЕ обновляется (обновление — вне провайдера),
// поэтому при постоянном 401 это даст 3 холостых повтора с backoff.
// Ретраится только для GET (см. условие в doRequest).
func isRetryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || // 429
return statusCode == http.StatusUnauthorized || // 401
statusCode == http.StatusTooManyRequests || // 429
statusCode == http.StatusServiceUnavailable || // 503
statusCode == http.StatusBadGateway || // 502
statusCode == http.StatusGatewayTimeout // 504
+12 -10
View File
@@ -45,7 +45,9 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload)
if err != nil {
return "", err
// Инстанс УЖЕ создан: возвращаем uid вместе с ошибкой, чтобы вызывающий
// мог зафиксировать его в state (иначе облачный инстанс осиротеет — Q5).
return instanceUid, err
}
opUid := extractUIDFromLocation(opHeaders.Get("Location"))
@@ -63,7 +65,7 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
}
}
if opUid == "" {
return "", fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location"))
return instanceUid, fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location"))
}
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
@@ -71,12 +73,12 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
hasOpDetails := false
if err == nil {
if jsonErr := json.Unmarshal(opDetailsResp, &opDetails); jsonErr != nil {
return "", fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr)
return instanceUid, fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr)
}
hasOpDetails = true
params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)
if err != nil {
return "", err
return instanceUid, err
}
}
@@ -89,7 +91,7 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return "", fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
return instanceUid, fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
}
sent[paramId] = true
}
@@ -118,27 +120,27 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return "", fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
return instanceUid, fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
}
}
}
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
if err != nil {
return "", fmt.Errorf("валидация не пройдена: %w", err)
return instanceUid, fmt.Errorf("валидация не пройдена: %w", err)
}
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
if err != nil {
return "", fmt.Errorf("выполнение не удалось: %w", err)
return instanceUid, fmt.Errorf("выполнение не удалось: %w", err)
}
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
if err := c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, serviceId, "create")); err != nil {
return "", err
return instanceUid, err
}
if err := c.ensureInstanceCreated(ctx, instanceUid); err != nil {
return "", err
return instanceUid, err
}
return instanceUid, nil
+53 -48
View File
@@ -1,68 +1,73 @@
package core
import (
"context"
"strings"
"terraform-provider-nubes/internal/core/jsonutil"
)
// modifierDesiredEqualsCurrent сравнивает желаемые значения полей модификатора
// (keyed by code) с живыми значениями из cfsParams (ParamValue).
// modifierValuesEqual сравнивает одно значение с учётом типа параметра:
// - array*/map*/json — смысловое JSON-сравнение (порядок ключей не значим);
// - скаляры — нормализация через normalizeUniversalValueV6 и сравнение строками.
func modifierValuesEqual(wanted, current string, param universalCfsParam) bool {
dataType := strings.ToLower(strings.TrimSpace(param.DataType))
if strings.HasPrefix(dataType, "array") ||
strings.HasPrefix(dataType, "map") ||
strings.Contains(dataType, "json") {
return jsonutil.JSONStringsEquivalent(wanted, current)
}
return normalizeUniversalValueV6(wanted, param) == normalizeUniversalValueV6(current, param)
}
// modifierDesiredEqualsLive — idempotency-pre-check по ЖИВЫМ параметрам инстанса
// (state.params), а не по paramValue формы операции.
//
// Возвращает true, если ВСЕ поля совпали (можно пропустить run при idempotency).
// Семантика сравнения:
// - bool/int/string: нормализуются через normalizeUniversalValueV6 и сравниваются строками;
// - map-fixed: JSON-сравнение (порядок ключей не значим);
// - array-map-fixed: JSON-сравнение сырых значений (normalize не строит дефолт для массивов).
func (c *UniversalClient) modifierDesiredEqualsCurrent(desired map[string]string, cfsParams []universalCfsParam) bool {
// Зачем: paramValue из cfsParams — это дефолт ФОРМЫ операции, а не состояние
// инстанса (см. instanceLiveParams, HAR/edge_.har). Пропуск modify на основе
// формы может быть ложным. Здесь источник — live.
//
// (true, nil) возвращается только если ВСЕ desired-поля найдены в live и совпали.
// Любая неопределённость (пусто, поле не найдено, ошибка) → (false, …): modify
// лучше выполнить лишний раз, чем пропустить нужный.
func (c *UniversalClient) modifierDesiredEqualsLive(ctx context.Context, instanceUid string, desired map[string]string, cfsParams []universalCfsParam) (bool, error) {
if len(desired) == 0 {
return false
return false, nil
}
codeToParam := make(map[string]universalCfsParam)
for _, p := range cfsParams {
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
codeToParam[key] = p
}
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
codeToParam[key] = p
}
live, err := c.instanceLiveParams(ctx, instanceUid)
if err != nil {
return false, err
}
if len(live) == 0 {
return false, nil
}
codeToParam := modifierCodeMap(cfsParams)
for code, wanted := range desired {
param, ok := codeToParam[strings.ToLower(strings.TrimSpace(code))]
if !ok {
// Код не найден в схеме — не можем сравнить, считаем «не равно».
return false
return false, nil
}
current := ""
if param.ParamValue != nil {
current = *param.ParamValue
current, ok := lookupLiveParam(live, param)
if !ok {
return false, nil
}
dataType := strings.ToLower(strings.TrimSpace(param.DataType))
if strings.HasPrefix(dataType, "array") {
// array-map-fixed: сравнивать сырые значения как JSON.
if !jsonutil.JSONStringsEquivalent(wanted, current) {
return false
}
continue
}
if strings.HasPrefix(dataType, "map") || strings.Contains(dataType, "json") {
if !jsonutil.JSONStringsEquivalent(wanted, current) {
return false
}
continue
}
// Скаляры: нормализуем обе стороны.
nw := normalizeUniversalValueV6(wanted, param)
nc := normalizeUniversalValueV6(current, param)
if nw != nc {
return false
if !modifierValuesEqual(wanted, current, param) {
return false, nil
}
}
return true
return true, nil
}
// modifierCodeMap индексирует cfsParams по нижнему регистру Code и SvcOperationCfsParam.
func modifierCodeMap(cfsParams []universalCfsParam) map[string]universalCfsParam {
m := make(map[string]universalCfsParam, len(cfsParams)*2)
for _, p := range cfsParams {
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
m[key] = p
}
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
m[key] = p
}
}
return m
}
+77 -40
View File
@@ -1,63 +1,100 @@
package core
import "testing"
func TestModifierDesiredEqualsCurrent_Scalars(t *testing.T) {
c := &UniversalClient{}
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 340, Code: "needEnableAVI", DataType: "boolean", ParamValue: strPtr("false")},
{SvcOperationCfsParamId: 369, Code: "virtualServicesCount", DataType: "integer > 0", ParamValue: strPtr("3")},
{SvcOperationCfsParamId: 856, Code: "qosProfile", DataType: "string", ParamValue: strPtr("QoS-100Mbit")},
}
import (
"context"
"net/http"
"testing"
)
func TestModifierValuesEqual_Scalars(t *testing.T) {
tests := []struct {
name string
desired map[string]string
want bool
name string
wanted, current string
param universalCfsParam
want bool
}{
{"все совпали", map[string]string{"needEnableAVI": "false", "virtualServicesCount": "3", "qosProfile": "QoS-100Mbit"}, true},
{"bool расхождение", map[string]string{"needEnableAVI": "true"}, false},
{"int расхождение", map[string]string{"virtualServicesCount": "1"}, false},
{"нормализация пробела", map[string]string{"virtualServicesCount": " 3 "}, true},
{"код не найден в схеме", map[string]string{"неизвестный": "x"}, false},
{"bool совпал", "false", "false", universalCfsParam{DataType: "boolean"}, true},
{"bool расхождение", "true", "false", universalCfsParam{DataType: "boolean"}, false},
{"int с пробелом", " 3 ", "3", universalCfsParam{DataType: "integer > 0"}, true},
{"int расхождение", "1", "3", universalCfsParam{DataType: "integer > 0"}, false},
{"string совпал", "QoS-100Mbit", "QoS-100Mbit", universalCfsParam{DataType: "string"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := c.modifierDesiredEqualsCurrent(tt.desired, cfsParams); got != tt.want {
t.Errorf("modifierDesiredEqualsCurrent() = %v, want %v", got, tt.want)
if got := modifierValuesEqual(tt.wanted, tt.current, tt.param); got != tt.want {
t.Errorf("modifierValuesEqual() = %v, want %v", got, tt.want)
}
})
}
}
func TestModifierDesiredEqualsCurrent_MapFixed(t *testing.T) {
c := &UniversalClient{}
func TestModifierValuesEqual_JSON(t *testing.T) {
mapParam := universalCfsParam{DataType: "map-fixed"}
// map-fixed: порядок ключей не значим
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 1112, Code: "routedNetConfiguration", DataType: "map-fixed",
ParamValue: strPtr(`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`)},
}
desired := map[string]string{
"routedNetConfiguration": `{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
}
if !c.modifierDesiredEqualsCurrent(desired, cfsParams) {
if !modifierValuesEqual(
`{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`,
mapParam) {
t.Errorf("map-fixed с разным порядком ключей должен считаться равным")
}
}
func TestModifierDesiredEqualsCurrent_ArrayPreservesOrder(t *testing.T) {
c := &UniversalClient{}
arrParam := universalCfsParam{DataType: "array-map-fixed"}
// array-map-fixed: порядок элементов массива значим
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 662, Code: "vIPConfigure", DataType: "array-map-fixed",
ParamValue: strPtr(`[{"name":"a","count":1},{"name":"b","count":2}]`)},
}
// тот же порядок — равно
if !c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"a","count":1},{"name":"b","count":2}]`}, cfsParams) {
if !modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"a","count":1},{"name":"b","count":2}]`, arrParam) {
t.Errorf("array-map-fixed с тем же порядком должен быть равен")
}
// другой порядок — не равно
if c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"b","count":2},{"name":"a","count":1}]`}, cfsParams) {
if modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"b","count":2},{"name":"a","count":1}]`, arrParam) {
t.Errorf("array-map-fixed с другим порядком не должен быть равен")
}
}
func TestModifierDesiredEqualsLive(t *testing.T) {
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 372, Code: "ipSpaceName", DataType: "string"},
}
newClient := func(liveParams string) (*UniversalClient, func()) {
return makeTestClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"instance":{"state":{"params":` + liveParams + `}}}`))
})
}
t.Run("совпало с live", func(t *testing.T) {
c, cleanup := newClient(`{"ipSpaceName":"internet-ipv4-v1"}`)
defer cleanup()
equal, err := c.modifierDesiredEqualsLive(context.Background(), "inst-1", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, cfsParams)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !equal {
t.Errorf("expected equal when live matches desired")
}
})
t.Run("отличается от live", func(t *testing.T) {
c, cleanup := newClient(`{"ipSpaceName":"no-needed"}`)
defer cleanup()
equal, err := c.modifierDesiredEqualsLive(context.Background(), "inst-1", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, cfsParams)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if equal {
t.Errorf("expected not equal when live differs from desired")
}
})
t.Run("live недоступен — ошибка, не пропускаем", func(t *testing.T) {
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
defer cleanup()
equal, err := c.modifierDesiredEqualsLive(context.Background(), "inst-1", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, cfsParams)
if err == nil {
t.Errorf("expected error when live is unavailable")
}
if equal {
t.Errorf("must not report equal when live is unavailable")
}
})
}
+18
View File
@@ -74,3 +74,21 @@ func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid str
}
return def.SvcOperation.CfsParams, nil
}
// fetchOperationSchemaByID возвращает схему операции по svcOperationId БЕЗ создания
// операции: GET /instanceOperations/default/{opId}.
//
// Зачем: idempotency pre-check обязан выполняться ДО POST /instanceOperations — иначе
// при пропуске останется созданная-но-незапущенная операция («черновик», pending),
// и следующий modify будет ждать idle до таймаута.
func (c *UniversalClient) fetchOperationSchemaByID(ctx context.Context, opId int) ([]universalCfsParam, error) {
defResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil)
if err != nil {
return nil, fmt.Errorf("не удалось получить схему операции %d: %w", opId, err)
}
var def universalOpDefaultResponse
if uerr := json.Unmarshal(defResp, &def); uerr != nil {
return nil, fmt.Errorf("не удалось разобрать схему операции %d: %w", opId, uerr)
}
return def.SvcOperation.CfsParams, nil
}
+19 -15
View File
@@ -41,6 +41,24 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
// Схема операции нужна и для idempotency pre-check, и для маппинга кодов.
// Получаем её ДО создания операции (GET /instanceOperations/default/{opId}): иначе
// при пропуске останется созданная, но незапущенная операция («черновик», pending).
schemaParams, err := c.fetchOperationSchemaByID(ctx, opId)
if err != nil {
return err
}
// Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса —
// выходим, НЕ создавая операцию вовсе (источник — state.params, см. instanceLiveParams).
if idempotent {
equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, schemaParams)
if liveErr == nil && equal {
return nil
}
// Ошибка/несовпадение live — не пропускаем: выполняем modify.
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
@@ -60,21 +78,7 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
return err
}
// Idempotency pre-check: если все desired уже равны live-значениям — пропускаем run.
// desired = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки.
if idempotent && c.modifierDesiredEqualsCurrent(params, cfsParams) {
return nil
}
codeToParam := make(map[string]universalCfsParam)
for _, p := range cfsParams {
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
codeToParam[key] = p
}
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
codeToParam[key] = p
}
}
codeToParam := modifierCodeMap(cfsParams)
paramsByID := map[int]string{}
for code, value := range params {
+6 -2
View File
@@ -17,8 +17,12 @@ import (
// указанного сервиса.
//
// ВАЖНО (uuid-case, 2026-03): API облака возвращает UUID всегда в нижнем регистре.
// Для отправки в API нормализуем к lowercase, но в state сохраняем регистр
// пользователя (см. шаблон instance.go, блоки "Restore user-provided casing").
// Для отправки в API нормализуем к lowercase. В state регистр пользователя сохраняется
// потому, что ref_svc-атрибуты ИСКЛЮЧЕНЫ из read-back (шаблон instance.go добавляет
// InputField только при `eq .RefSvcId 0`), а UUID-подстроки ВНУТРИ JSON нормализуются
// при отправке (`resources_core.BuildJSON` -> `jsonutil.LowercaseUUIDsInText`).
// (Блока "Restore user-provided casing" в instance.go нет — он описан только в доке
// docs/60_strategy/terraform_case_sensitivity_fix.md как исторический подход.)
func (c *UniversalClient) resolveRefSvcParamValues(ctx context.Context, opParams []universalCfsParam, params map[int]string) (map[int]string, error) {
if len(params) == 0 || len(opParams) == 0 {
+9 -1
View File
@@ -6,6 +6,8 @@ import (
"strings"
"github.com/hashicorp/terraform-plugin-framework/types"
"terraform-provider-nubes/internal/core/jsonutil"
)
func FormatBool(v types.Bool) string {
@@ -81,6 +83,12 @@ func CompactParams(params map[string]string) map[string]string {
// BuildJSON строит JSON-строку из map[string]string значений.
// Ключи не кавычатся если они уже в кавычках.
//
// FIX(uuid-case): после сборки приводим UUID-подстроки к lowercase. Облако хранит
// UUID в lowercase, но ресурсы могут отдавать id в UPPERCASE (например nsxtUid у
// кластера Штурвала), а платформа сравнивает регистр при create и падает с
// «Edge не развёрнут в указанном vDC». Без этой нормализации приходилось писать
// lower(...) в HCL у каждого такого поля.
func BuildJSON(parts map[string]string) string {
if len(parts) == 0 {
return "{}"
@@ -89,5 +97,5 @@ func BuildJSON(parts map[string]string) string {
for k, v := range parts {
pairs = append(pairs, fmt.Sprintf(`"%s":%s`, k, v))
}
return "{" + strings.Join(pairs, ",") + "}"
return jsonutil.LowercaseUUIDsInText("{" + strings.Join(pairs, ",") + "}")
}
@@ -225,6 +225,24 @@ func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportS
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)...)
// Required-атрибут ip_space_name нельзя оставить null после импорта: пользователь
// обязан указать его в конфиге. Заполняем live-значением; если SNAT не выставлен —
// каноном «выключено» (no-needed).
var live map[string]string
if r.client != nil {
live, _ = r.client.GetInstanceStateParams(ctx, uid)
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("ip_space_name"), importIpSpaceName(live))...)
}
// importIpSpaceName выбирает значение ip_space_name для состояния после импорта:
// live-значение, если есть; иначе канон «SNAT выключен» (no-needed).
func importIpSpaceName(live map[string]string) string {
if v := strings.TrimSpace(live["ipSpaceName"]); v != "" {
return v
}
return noNeededIpSpace
}
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
@@ -250,9 +268,10 @@ func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ip
unlock := r.client.LockInstance(uid)
defer unlock()
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
// а не с live-состоянием инстанса — можно ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
// Idempotent: core.RunInstanceOperationUniversalByIdempotent выполняет pre-check
// по LIVE-значениям инстанса (state.params) и пропускает modify, если
// ipSpaceName уже совпадает — повторный apply не дёргает платформу зря.
return r.client.RunInstanceOperationUniversalByIdempotent(ctx, uid, "modify", map[string]string{
"ipSpaceName": value,
})
}
@@ -21,6 +21,14 @@ var _ resource.Resource = &OrgIpAllocationResource{}
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
// ===== Именованные ссылки на сервисы платформы (реестр данных, без магических чисел) =====
// Проверяется стражем TOOLS/scripts/check_hardcoded_service_ids.sh.
const (
// svcIDVcOrg — сервис 19 «vc_org»: организация Cloud Director.
// Организация создаётся вручную в ЛК, ресурс адресует её по имени/UUID.
svcIDVcOrg = 19
)
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
//
@@ -291,6 +299,25 @@ func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
// Required-атрибут vip_configure нельзя оставить null после импорта.
// Заполняем live-значением; если аллокации нет — пустым массивом.
var live map[string]string
if r.client != nil {
live, _ = r.client.GetInstanceStateParams(ctx, uid)
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("vip_configure"), importVipConfigure(live))...)
}
// importVipConfigure выбирает значение vip_configure для состояния после импорта:
// канонический JSON из live (если есть непустые элементы), иначе пустой массив.
func importVipConfigure(live map[string]string) string {
if raw, ok := live["vIPConfigure"]; ok {
if items, err := parseVipConfigure(raw); err == nil && len(items) > 0 {
return formatVipConfigure(items)
}
}
return "[]"
}
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
@@ -305,7 +332,7 @@ func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, or
return "", fmt.Errorf("organization обязателен")
}
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
resolved, err := r.client.ResolveRefSvcParamValue(ctx, svcIDVcOrg, raw)
if err != nil {
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
}
@@ -51,6 +51,48 @@ func TestFormatVipConfigure_Canonical(t *testing.T) {
}
}
// importVipConfigure/importIpSpaceName — выбор значения Required-атрибутов при импорте
// (B7: Required не должен оставаться null после import).
func TestImportVipConfigure(t *testing.T) {
cases := []struct {
name string
live map[string]string
want string
}{
{"live-аллокация", map[string]string{"vIPConfigure": `[{"name":"internet-ipv4-v1","count":"3"}]`}, `[{"name":"internet-ipv4-v1","count":"3"}]`},
{"нет ключа", map[string]string{}, "[]"},
{"nil", nil, "[]"},
{"свежая орга [{}]", map[string]string{"vIPConfigure": `[{}]`}, "[]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := importVipConfigure(tc.live); got != tc.want {
t.Fatalf("получено %q, ожидалось %q", got, tc.want)
}
})
}
}
func TestImportIpSpaceName(t *testing.T) {
cases := []struct {
name string
live map[string]string
want string
}{
{"live-значение", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, "internet-ipv4-v1"},
{"нет ключа", map[string]string{}, "no-needed"},
{"nil", nil, "no-needed"},
{"только пробелы", map[string]string{"ipSpaceName": " "}, "no-needed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := importIpSpaceName(tc.live); got != tc.want {
t.Fatalf("получено %q, ожидалось %q", got, tc.want)
}
})
}
}
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
+3 -3
View File
@@ -5,12 +5,12 @@ set -euo pipefail
#
# Usage examples:
# ./scripts/publish-doc-page.sh \
# --profile devops/profiles/test \
# --profile TOOLS/config/test \
# --version 5.0.17 \
# --page 30_registry/guides/getting-started/index.html
#
# ./scripts/publish-doc-page.sh \
# --profile devops/profiles/prod \
# --profile TOOLS/config/prod \
# --version 2.1.26 \
# --page 30_registry/guides/getting-started/
@@ -31,7 +31,7 @@ Usage:
publish-doc-page.sh --profile <path> --version <ver> --page <relative-path> [--site-dir <dir>]
Required:
--profile Path to stand profile dir (e.g. devops/profiles/test)
--profile Path to stand profile dir (e.g. TOOLS/config/test)
--version Docs version (e.g. 5.0.17)
--page Relative path inside built site dir, e.g.:
30_registry/guides/getting-started/index.html