Files
tf_provider/PLAN_modifier_redesign.md
T

12 KiB
Raw Blame History

ПЛАН реализации: редизайн ресурсов-модификаторов (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).


Порядок коммитов (по смыслу)

  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.