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