12 KiB
ПЛАН реализации: редизайн ресурсов-модификаторов (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):
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.
Добавить:
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):
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}:
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).
Порядок коммитов (по смыслу)
feat(lib): delete_strategy/idempotency/delete_params в OperationSpecfeat(gen): GenModifier расширение + LoadSpecs + ValidateSpec + normalize-helpersrefactor(core): вынести JSON-эквивалентность в jsonutil (+реэкспорт)feat(core): modifierDesiredEqualsCurrent + RunOperationByCodeIdempotentfeat(gen): шаблон modifier — reconcile(override), Delete стратегия, ID identityfeat(yaml): реестр исключений модификаторов (delete_strategy/idempotency)test(core,gen): unit-кейсыchore(dev): bump версии
Открытый вопрос — закрыт
Источник-канон — реестр serviceSpecificModifiers в TOOLS/yaml-generator/main.go.
Разметка delete_strategy/idempotency расширяет этот реестр, а НЕ правится вручную
в generated/dev.
Решения, нуждающиеся в подтверждении (из ревью)
- ID=identity → РЕШЕНО: формат ID НЕ меняем (оставить
uid:operation:modifier). Смена формата форснёт replace у задеплоенных модификаторов и вызовет баг E. Идемпотентность — через pre-check, не через ID. Шаг 4.3 отменён (ID остаётся как есть). error+ replace. Пользователь не сможет заменить error-модификатор. Предлагаю: оставитьerrorтолько для «чистого» destroy, документировать запрет replace.- Разметка по default —
ip_space:delete_strategy=error,idempotency=check_before_run;network:delete_strategy=inverse, delete_params=[needEnableAVI=false], idempotency=none.