# ПЛАН реализации: редизайн ресурсов-модификаторов (kind: modifier) Основа: `HISTORY/OPUS/2026-09-22_modifier_architecture_project.md`. Ревью плана: `HISTORY/OPUS/2026-09-22_modifier_plan_review.md`. Цель — закрыть все классы багов A–E, без костылей, по согласованной архитектуре. Порядок шагов (исправлен по ревью): шаблон (4) зависит от core/resources_core (5–7), поэтому: 1 → 2 → 3 → 5 → 6 → 7 → 4 → 8 → регенерация → 9 → 10. --- ## Шаг 1. Контракт YAML в `TOOLS/lib/types.go` Файл: `TOOLS/lib/types.go`, `OperationSpec`. Добавить поля (тег yaml, omitempty): ```go DeleteStrategy string `yaml:"delete_strategy,omitempty"` // "" → noop_warn Idempotency string `yaml:"idempotency,omitempty"` // "" → none DeleteParams []ParamSpec `yaml:"delete_params,omitempty"` ``` Enum `delete_strategy`: `noop_warn` | `inverse` | `error`. Enum `idempotency`: `none` | `check_before_run`. Проверка: `TOOLS/resource-generator` получает поля через алиас `OperationSpec = lib.OperationSpec` — отдельной правки не нужно, но `go build ./...` в lib и в resource-generator. --- ## Шаг 2. GenModifier — производные поля Файл: `TOOLS/resource-generator/internal/types/types.go`, `GenModifier`. Добавить: ```go DeleteStrategy string // нормализованный enum (noop_warn|inverse|error) Idempotency string // none|check_before_run DeleteParams []Param // из spec.DeleteParams (ConvertParams), только при inverse ``` --- ## Шаг 3. LoadSpecs — заполнение modifier + валидация Файл: `TOOLS/resource-generator/internal/loader/loader.go`, ветка `op.Kind == "modifier"`. До `continue`: - `modifier.DeleteStrategy = normalizeDeleteStrategy(op.DeleteStrategy)` (пусто → `noop_warn`); - `modifier.Idempotency = normalizeIdempotency(op.Idempotency)` (пусто → `none`); - `modifier.DeleteParams = ConvertParams(op.DeleteParams)` (при inverse). Helpers `normalizeDeleteStrategy`/`normalizeIdempotency` — добавить в `loader.go` (тот же пакет, рядом с веткой modifier). Файл: `loader.go`, `ValidateSpec` — расширить fail-fast для modifier: - `delete_strategy` вне enum → ошибка; - `delete_strategy == "inverse"` и пуст `delete_params` → ошибка; - каждый `delete_params.code` обязан существовать в `op.Params` (сравнение по lower-code) → иначе ошибка; - `idempotency` вне enum → ошибка. --- ## Шаг 4. Шаблон `modifier.go` — редизайн Файл: `TOOLS/resource-generator/internal/templates/modifier.go`. 4.1. **Убрать `CompactParams`** — в Create/Update передавать map напрямую (все заданные поля; решение о досылке — в core). 4.2. **Единый `reconcile()`** — вынести общее тело Create/Update в приватный метод `reconcile(ctx, model *Model, override map[string]string)`, вызываемый из Create и Update (override=nil). Устраняет дубль веток. **override нужен для Delete=inverse** (см. 4.4), так как Delete не имеет plan — только state. 4.3. **ID = identity** — `plan.ID = BuildActionID(instanceUID, modifierName)` (убрать operation из ID). Реализовать через существующий `BuildActionID(instanceUID, "", modifierName)` или новый helper `BuildModifierID(instanceUID, modifierName)`. ⚠️ **миграция state:** смена формата ID изменит ID уже задеплоенных модификаторов → Terraform форснёт replace. Принять решение ДО: сохранить старый формат ИЛИ явный state-migration план. По умолчанию — сохранить формат `uid:operation:modifier`, не менять формат. 4.4. **Delete по стратегии**: ``` {{- if eq .DeleteStrategy "error" }} Delete → AddError (запрет destroy); ⚠️ конфликт с replace: replace = Delete→Create, при error пользователь не сможет заменить модификатор. Решение: запретить replace у error-модификаторов (документировать) или отличить «чистый destroy» от replace. {{- else if eq .DeleteStrategy "inverse" }} Delete → reconcile(state-model, override=delete_params) (delete_params — финальные wire-строки: "false", готовый JSON; обработать как override) {{- else }} Delete → RemoveResource + AddWarning («эффект остаётся на платформе») {{- end }} ``` 4.5. **Pre-check idempotency** — в reconcile при `eq .Idempotency "check_before_run"`: передавать флаг в вызов операции (см. шаг 6). ⚠️ при unknown (computed ref) pre-check skip — сравнение невозможно. --- ## Шаг 5. JSON-эквивалентность в нейтральный пакет (снять цикл импорта) Проблема: `JSONStringsEquivalent` в `resources_core`, а comparison нужен в `core`. - создать `provider/internal/core/jsonutil/jsonutil.go`: перенести `JSONStringsEquivalent` + `normalizeJSONIfPossible` + `encodeCanonicalJSON` + `writeCanonicalJSON` + `normalizeJSONScalarsToStrings` из `resources_core/json_normalize.go`; - `resources_core/json_normalize.go` — **оставить реэкспорт-обёртку** `JSONStringsEquivalent` (не заменять вызовы по resources_core — иначе диф на инстансы). Проверка: `go build ./...`, нет цикла импорта. --- ## Шаг 6. core — pre-check `modifierDesiredEqualsCurrent` Файл: `provider/internal/core/operation_run_bycode.go` (или новый `modifier_compare.go`). Добавить (unexported, вызов внутри core): ```go func (c *UniversalClient) modifierDesiredEqualsCurrent( desired map[string]string, cfsParams []universalCfsParam) bool ``` Логика: - маппинг code→param по **двум** алиасам: `p.Code` И `p.SvcOperationCfsParam` (как в operation_run_bycode.go:50-58); - для каждого desired-кода → live `ParamValue`; - bool/int/string → нормализовать обе стороны `normalizeUniversalValueV6` + сравнение строк; - map-fixed → `jsonutil.JSONStringsEquivalent`; - **array-map-fixed → `jsonutil.JSONStringsEquivalent` по сырым значениям, НЕ через normalize** (`normalizeUniversalValueV6` не строит дефолт для array-map-fixed, params.go:33); - desired — только явно заданные коды (до досылки live/default); - если desired содержит unknown (computed ref) — сравнение невозможно, pre-check пропустить. Опционально: добавить в `RunInstanceOperationUniversalByCode` параметр `idempotent bool` (или новый метод-обёртка). В `operation_run_bycode.go` после `fetchOperationCfsParams`: ``` if idempotent && c.modifierDesiredEqualsCurrent(paramsByID, cfsParams) { return nil // skip run } ``` idle-гейт (`waitForInstanceIdle`) уже стоит выше — не трогать. --- ## Шаг 7. Передача флага `idempotent` вплоть до client Цепочка: шаблон → `resources_core.RunOperationByCodeWithTimeout` → `core.RunInstanceOperationUniversalByCode`. - **добавить НОВЫЙ метод `RunOperationByCodeIdempotent(...)` в `resources_core/crud.go`**, НЕ менять сигнатуру `RunOperationByCodeWithTimeout` (его зовут инстансы); - пробросить флаг в `RunInstanceOperationUniversalByCode` (новый параметр или обёртка). --- ## Шаг 8. YAML-разметка (источник-канон) в `TOOLS/yaml-generator` Источник-канон — реестр исключений `serviceSpecificModifiers` в `TOOLS/yaml-generator/main.go` (Ключ — имя сервиса → имя modifier). `generated/dev` перегенерируется — туда НЕ вносить вручную. Контракт в `lib.OperationSpec` (алиас в обоих генераторах), значит yaml-generator должен проставлять флаги при маршале. Расширить реестр со `map[string]string` до структуры, несущей: `ModifierName`, `DeleteStrategy`, `Idempotency`, `DeleteParams []struct{Code,Value}`: ```go type modifierException struct { ModifierName string DeleteStrategy string // noop_warn | inverse | error Idempotency string // none | check_before_run DeleteParams []deleteParam // только для inverse } type deleteParam struct { Code, Value string } var serviceSpecificModifiers = map[string]modifierException{ "vc_org": {ModifierName: "ip_space", DeleteStrategy: "error", Idempotency: "check_before_run"}, "vc_nsxt": {ModifierName: "network", DeleteStrategy: "inverse", DeleteParams: []deleteParam{{"needEnableAVI", "false"}}}, } ``` В цикле над ops (там, где `Kind="modifier"`): проставить `op.DeleteStrategy`, `op.Idempotency`, `op.DeleteParams`. ⚠️ При переходе с `map[string]string` на структуру: `ModifierName` берётся из структуры (сейчас `modName, ok := serviceSpecificModifiers[name]` — строка 95 main.go). --- ## Порядок коммитов (по смыслу) 1. `feat(lib): delete_strategy/idempotency/delete_params в OperationSpec` 2. `feat(gen): GenModifier расширение + LoadSpecs + ValidateSpec + normalize-helpers` 3. `refactor(core): вынести JSON-эквивалентность в jsonutil (+реэкспорт)` 4. `feat(core): modifierDesiredEqualsCurrent + RunOperationByCodeIdempotent` 5. `feat(gen): шаблон modifier — reconcile(override), Delete стратегия, ID identity` 6. `feat(yaml): реестр исключений модификаторов (delete_strategy/idempotency)` 7. `test(core,gen): unit-кейсы` 8. `chore(dev): bump версии` --- ## Открытый вопрос — закрыт Источник-канон — реестр `serviceSpecificModifiers` в `TOOLS/yaml-generator/main.go`. Разметка `delete_strategy`/`idempotency` расширяет этот реестр, а НЕ правится вручную в `generated/dev`. --- ## Решения, нуждающиеся в подтверждении (из ревью) 1. **ID=identity → миграция state.** Смена формата заставит Terraform replace уже задеплоенных модификаторов. Предлагаю: НЕ менять формат ID (оставить `uid:operation:modifier`), а idempotency обеспечить pre-check, не трогая ID. 2. **`error` + replace.** Пользователь не сможет заменить error-модификатор. Предлагаю: оставить `error` только для «чистого» destroy, документировать запрет replace. 3. **Разметка по default** — `ip_space`: `delete_strategy=error`, `idempotency=check_before_run`; `network`: `delete_strategy=inverse`, delete_params=[needEnableAVI=false], idempotency=none.