docs(plan): внести правки ревью Опуса в план (порядок, ID-миграция, override, array-map-fixed)

This commit is contained in:
Repinoid
2026-09-22 20:57:54 +03:00
parent e06a2c0b11
commit 2933c57a4b
2 changed files with 126 additions and 19 deletions
@@ -0,0 +1,72 @@
# Opus: ревью плана редизайна модификаторов — 2026-09-22
Источник: ответ на `prompt_for_opus_modifier_plan_review.md` (план `PLAN_modifier_redesign.md`).
## 1. Порядок шагов — скрытые зависимости
- Шаг 4 (шаблон) ссылается на API из шагов 6–7 → **сначала 5→6→7, потом 4**.
- Шаг 8 (yaml-generator) должен идти ДО регенерации `dev` и до сборки.
Скорректированный порядок: 1 → 2 → 3 → 5 → 6 → 7 → 4 → 8 → регенерация → 9 → 10.
## 2. Шаг 5 (вынос JSON-эквивалентности)
Путь верен. **Оставить реэкспорт-обёртку `JSONStringsEquivalent` в `json_normalize.go`**,
не заменять вызовы по всему resources_core (иначе диф на инстансы, вопрос 7).
Переносятся самодостаточные 5 функций: `JSONStringsEquivalent`, `normalizeJSONIfPossible`,
`encodeCanonicalJSON`, `writeCanonicalJSON`, `normalizeJSONScalarsToStrings`.
Вариант «готовые строки в core» — отклонить (размазывает нормализацию, не снимает
потребность в JSONStringsEquivalent в core).
## 3. Шаг 6 — сигнатура и сравнение
- Маппинг code→param по **двум** алиасам: `p.Code` И `p.SvcOperationCfsParam` (как в
operation_run_bycode.go:50-58). Один `Code` даст пропуски.
- Имя `modifierDesiredEqualsCurrent` — unexported, вызов внутри core. Слово «экспортированный» убрать.
- bool/int/string — `normalizeUniversalValueV6` + сравнение. map-fixed — `JSONStringsEquivalent`.
- **array-map-fixed — дыра:** `normalizeUniversalValueV6` строит дефолт только для
`map-fixed`/`HasPrefix "map"` (params.go:33); `array-map-fixed` туда не попадает →
сравнивать сырые значения через `jsonutil.JSONStringsEquivalent`, не через normalize.
- desired = только явно заданные коды (до досылки live/default), иначе pre-check всегда «равно».
## 4. Шаг 4.4 Delete=inverse — подводный камень
- Delete не имеет `plan` (только `req.State`). `reconcile(ctx, plan *Model)` не подходит.
`reconcile(ctx, model *Model, override map[string]string)`; для inverse override = delete_params.
- `deleteParams` — финальные **wire-строки** (`"false"`, готовый JSON), БЕЗ прогонки через
`ParamFormat`/тип. В реестре `deleteParam{Code, Value}` несёт готовую строку.
## 5. Шаг 8 — расширение реестра
Верно. Держать в `serviceSpecificModifiers` (main.go:34), не отдельным реестром.
Структура `modifierException` корректна. При переходе со `map[string]string` на структуру:
`ModifierName` берётся из структуры (сейчас `modName, ok := serviceSpecificModifiers[name]`
— строка 95).
## 6. Пропущенные кейсы
- **taint/replace + `delete_strategy=error`** — конфликт: replace = Delete→Create, Delete=error
блокирует → пользователь не сможет заменить error-модификатор. Решить явно:
запретить replace у error (документировать) или отличить «чистый destroy» от replace.
- **unknown в pre-check** — при unknown (computed ref) сравнение невозможно; шаг 6 должен
skip-ить pre-check при unknown (иначе пустая строка даст ложный diff/панику).
- **partial apply** — досылка live для незаданных + pre-check; проверить кейс «часть задана, часть live».
## 7. Риск сломать инстансы
Низкий при условиях:
- **НЕ удалять `CompactParams`** (helpers.go:68) — убирается только из modifier-шаблона;
функция нужна инстанс/action.
- **Шаг 7 — новый метод `RunOperationByCodeIdempotent`, НЕ менять сигнатуру**
`RunOperationByCodeWithTimeout`/`RunInstanceOperationUniversalByCode` (зовут инстансы).
- Шаг 1 (поля OperationSpec) аддитивен — безопасно.
## Дополнительно (не в вопросах)
- **Шаг 4.3 (ID=identity) — ломающая миграция state.** Смена формата ID изменит ID уже
задеплоенных модификаторов → Terraform форснёт replace. Нужно: либо сохранить старый
формат ID, либо явный state-migration plan. В плане не отмечено.
- **Шаг 3 — `normalizeDeleteStrategy`/`normalizeIdempotency`** — где живут (в loader.go, рядом
с веткой modifier). Не указано.
- **ValidateSpec** — проверка `delete_params.code ∈ op.Params` по lower-code; сверить поле `Code`.
+54 -19
View File
@@ -1,8 +1,11 @@
# ПЛАН реализации: редизайн ресурсов-модификаторов (kind: modifier) # ПЛАН реализации: редизайн ресурсов-модификаторов (kind: modifier)
Основа: `HISTORY/OPUS/2026-09-22_modifier_architecture_project.md`. Основа: `HISTORY/OPUS/2026-09-22_modifier_architecture_project.md`.
Ревью плана: `HISTORY/OPUS/2026-09-22_modifier_plan_review.md`.
Цель — закрыть все классы багов A–E, без костылей, по согласованной архитектуре. Цель — закрыть все классы багов A–E, без костылей, по согласованной архитектуре.
Порядок шагов строгий: контракт → загрузка → шаблон → core → yaml → пересборка.
Порядок шагов (исправлен по ревью): шаблон (4) зависит от core/resources_core (57),
поэтому: 1 → 2 → 3 → 5 → 6 → 7 → 4 → 8 → регенерация → 9 → 10.
--- ---
@@ -45,6 +48,9 @@ DeleteParams []Param // из spec.DeleteParams (ConvertParams), толь
- `modifier.Idempotency = normalizeIdempotency(op.Idempotency)` (пусто → `none`); - `modifier.Idempotency = normalizeIdempotency(op.Idempotency)` (пусто → `none`);
- `modifier.DeleteParams = ConvertParams(op.DeleteParams)` (при inverse). - `modifier.DeleteParams = ConvertParams(op.DeleteParams)` (при inverse).
Helpers `normalizeDeleteStrategy`/`normalizeIdempotency` — добавить в `loader.go`
(тот же пакет, рядом с веткой modifier).
Файл: `loader.go`, `ValidateSpec` — расширить fail-fast для modifier: Файл: `loader.go`, `ValidateSpec` — расширить fail-fast для modifier:
- `delete_strategy` вне enum → ошибка; - `delete_strategy` вне enum → ошибка;
- `delete_strategy == "inverse"` и пуст `delete_params` → ошибка; - `delete_strategy == "inverse"` и пуст `delete_params` → ошибка;
@@ -61,25 +67,34 @@ DeleteParams []Param // из spec.DeleteParams (ConvertParams), толь
(все заданные поля; решение о досылке — в core). (все заданные поля; решение о досылке — в core).
4.2. **Единый `reconcile()`** — вынести общее тело Create/Update в приватный метод 4.2. **Единый `reconcile()`** — вынести общее тело Create/Update в приватный метод
`reconcile(ctx, plan *Model)`, вызываемый из 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)` 4.3. **ID = identity**`plan.ID = BuildActionID(instanceUID, modifierName)`
(убрать operation из ID). Реализовать через существующий `BuildActionID(instanceUID, "", modifierName)` (убрать operation из ID). Реализовать через существующий `BuildActionID(instanceUID, "", modifierName)`
или новый helper `BuildModifierID(instanceUID, modifierName)`. или новый helper `BuildModifierID(instanceUID, modifierName)`.
⚠️ **миграция state:** смена формата ID изменит ID уже задеплоенных модификаторов →
Terraform форснёт replace. Принять решение ДО: сохранить старый формат ИЛИ явный
state-migration план. По умолчанию — сохранить формат `uid:operation:modifier`, не менять формат.
4.4. **Delete по стратегии**: 4.4. **Delete по стратегии**:
``` ```
{{- if eq .DeleteStrategy "error" }} {{- if eq .DeleteStrategy "error" }}
Delete → AddError (запрет destroy) Delete → AddError (запрет destroy); ⚠️ конфликт с replace: replace = Delete→Create,
при error пользователь не сможет заменить модификатор. Решение: запретить replace
у error-модификаторов (документировать) или отличить «чистый destroy» от replace.
{{- else if eq .DeleteStrategy "inverse" }} {{- else if eq .DeleteStrategy "inverse" }}
Delete → modify с DeleteParams + досылка live остальных (reconcile-вариант) Delete → reconcile(state-model, override=delete_params)
(delete_params — финальные wire-строки: "false", готовый JSON; обработать как override)
{{- else }} {{- else }}
Delete → RemoveResource + AddWarning («эффект остаётся на платформе») Delete → RemoveResource + AddWarning («эффект остаётся на платформе»)
{{- end }} {{- end }}
``` ```
4.5. **Pre-check idempotency** — в reconcile при `eq .Idempotency "check_before_run"`: 4.5. **Pre-check idempotency** — в reconcile при `eq .Idempotency "check_before_run"`:
передавать флаг в вызов операции (см. шаг 6). передавать флаг в вызов операции (см. шаг 6). ⚠️ при unknown (computed ref) pre-check
skip — сравнение невозможно.
--- ---
@@ -89,8 +104,8 @@ DeleteParams []Param // из spec.DeleteParams (ConvertParams), толь
- создать `provider/internal/core/jsonutil/jsonutil.go`: - создать `provider/internal/core/jsonutil/jsonutil.go`:
перенести `JSONStringsEquivalent` + `normalizeJSONIfPossible` + `encodeCanonicalJSON` + перенести `JSONStringsEquivalent` + `normalizeJSONIfPossible` + `encodeCanonicalJSON` +
`writeCanonicalJSON` + `normalizeJSONScalarsToStrings` из `resources_core/json_normalize.go`; `writeCanonicalJSON` + `normalizeJSONScalarsToStrings` из `resources_core/json_normalize.go`;
- `resources_core/json_normalize.go` оставить как обёртку (реэкспорт `jsonutil.JSONStringsEquivalent`) - `resources_core/json_normalize.go` **оставить реэкспорт-обёртку** `JSONStringsEquivalent`
или заменить вызовы на `jsonutil.JSONStringsEquivalent`. (не заменять вызовы по resources_core — иначе диф на инстансы).
Проверка: `go build ./...`, нет цикла импорта. Проверка: `go build ./...`, нет цикла импорта.
@@ -100,16 +115,21 @@ DeleteParams []Param // из spec.DeleteParams (ConvertParams), толь
Файл: `provider/internal/core/operation_run_bycode.go` (или новый `modifier_compare.go`). Файл: `provider/internal/core/operation_run_bycode.go` (или новый `modifier_compare.go`).
Добавить экспортированный: Добавить (unexported, вызов внутри core):
```go ```go
func (c *UniversalClient) modifierDesiredEqualsCurrent( func (c *UniversalClient) modifierDesiredEqualsCurrent(
desired map[string]string, cfsParams []universalCfsParam) bool desired map[string]string, cfsParams []universalCfsParam) bool
``` ```
Логика: Логика:
- для каждого desired-кода → найти `universalCfsParam` → live `ParamValue`; - маппинг code→param по **двум** алиасам: `p.Code` И `p.SvcOperationCfsParam`
- нормализовать обе стороны `normalizeUniversalValueV6`; (как в operation_run_bycode.go:50-58);
- map-fixed/array-map-fixed → `jsonutil.JSONStringsEquivalent`; - для каждого desired-кода → live `ParamValue`;
- все поля совпали → true. - 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` Опционально: добавить в `RunInstanceOperationUniversalByCode` параметр `idempotent bool`
(или новый метод-обёртка). В `operation_run_bycode.go` после `fetchOperationCfsParams`: (или новый метод-обёртка). В `operation_run_bycode.go` после `fetchOperationCfsParams`:
@@ -125,9 +145,9 @@ idle-гейт (`waitForInstanceIdle`) уже стоит выше — не тро
## Шаг 7. Передача флага `idempotent` вплоть до client ## Шаг 7. Передача флага `idempotent` вплоть до client
Цепочка: шаблон → `resources_core.RunOperationByCodeWithTimeout``core.RunInstanceOperationUniversalByCode`. Цепочка: шаблон → `resources_core.RunOperationByCodeWithTimeout``core.RunInstanceOperationUniversalByCode`.
- добавить вариант `RunOperationByCodeIdempotent(...)` в `resources_core/crud.go` - **добавить НОВЫЙ метод `RunOperationByCodeIdempotent(...)` в `resources_core/crud.go`**,
(или расширить сигнатуру существующей, не ломая другие вызовы); НЕ менять сигнатуру `RunOperationByCodeWithTimeout` (его зовут инстансы);
- пробросить флаг в `RunInstanceOperationUniversalByCode`. - пробросить флаг в `RunInstanceOperationUniversalByCode` (новый параметр или обёртка).
--- ---
@@ -161,15 +181,18 @@ var serviceSpecificModifiers = map[string]modifierException{
В цикле над ops (там, где `Kind="modifier"`): проставить `op.DeleteStrategy`, В цикле над ops (там, где `Kind="modifier"`): проставить `op.DeleteStrategy`,
`op.Idempotency`, `op.DeleteParams`. `op.Idempotency`, `op.DeleteParams`.
⚠️ При переходе с `map[string]string` на структуру: `ModifierName` берётся из структуры
(сейчас `modName, ok := serviceSpecificModifiers[name]` — строка 95 main.go).
--- ---
## Порядок коммитов (по смыслу) ## Порядок коммитов (по смыслу)
1. `feat(lib): delete_strategy/idempotency/delete_params в OperationSpec` 1. `feat(lib): delete_strategy/idempotency/delete_params в OperationSpec`
2. `feat(gen): GenModifier расширение + LoadSpecs + ValidateSpec` 2. `feat(gen): GenModifier расширение + LoadSpecs + ValidateSpec + normalize-helpers`
3. `refactor(gen): шаблон modifier — reconcile, без CompactParams, ID identity, Delete стратегия` 3. `refactor(core): вынести JSON-эквивалентность в jsonutil (+реэкспорт)`
4. `refactor(core): вынести JSON-эквивалентность в jsonutil` 4. `feat(core): modifierDesiredEqualsCurrent + RunOperationByCodeIdempotent`
5. `feat(core): modifierDesiredEqualsCurrent + флаг idempotent` 5. `feat(gen): шаблон modifier — reconcile(override), Delete стратегия, ID identity`
6. `feat(yaml): реестр исключений модификаторов (delete_strategy/idempotency)` 6. `feat(yaml): реестр исключений модификаторов (delete_strategy/idempotency)`
7. `test(core,gen): unit-кейсы` 7. `test(core,gen): unit-кейсы`
8. `chore(dev): bump версии` 8. `chore(dev): bump версии`
@@ -181,3 +204,15 @@ var serviceSpecificModifiers = map[string]modifierException{
Источник-канон — реестр `serviceSpecificModifiers` в `TOOLS/yaml-generator/main.go`. Источник-канон — реестр `serviceSpecificModifiers` в `TOOLS/yaml-generator/main.go`.
Разметка `delete_strategy`/`idempotency` расширяет этот реестр, а НЕ правится вручную Разметка `delete_strategy`/`idempotency` расширяет этот реестр, а НЕ правится вручную
в `generated/dev`. в `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.