Files
tf_provider/HISTORY/OPUS/2026-09-22_modifier_architecture_project.md
T

11 KiB
Raw Blame History

Opus: архитектура модификаторов (project, полный) — 2026-09-22

Источник: ответ Opus на prompt_for_opus_modifier_architecture_full.md.

Ключевая модель

Модификатор — декларативная проекция подмножества полей родителя, а не «действие». Отсюда:

  • один reconcile (Create ≡ Update), не два разных пути;
  • payload всегда полный по своим полям (не дельта);
  • источник истины — родитель; модификатор в state хранит только read-back.

1. Сравнить и применить — полный payload, не дельта

Дельта запрещена: бэкенд трактует отсутствующий параметр как reset-to-default (класс A). Reconcile:

  1. взять все SchemaParams;
  2. заданные пользователем → значение из плана;
  3. незаданные → live → default (уже в operation_run_bycode.go);
  4. drift в Read — сравнение модели с state_params родителя (state_refresh.go).

2. Досылка незаданных (заливы A и B) — канон

CompactParams в шаблоне + досылка в клиенте — два конца одного бага. Правило по приоритету (уже в operation_run_bycode.go:98-118):

Ситуация Что слать
задан пользователем значение из плана
не задан, есть live ParamValue live
не задан, нет live, есть DefaultValue дефолт
не задан, ничего нет пропустить (не синтезировать)

Дыра: CompactParams в modifier.go:92 выкидывает пустые ДО клиента (теряется «задал пусто» vs «не задал»). → Убрать CompactParams из шаблона модификатора, передавать map напрямую. Единственная точка решения — клиент. CompactParams оставить только для instance-ресурсов.

3. Delete / rollback

No-op Delete = скрытый drift (класс D). Пока обратный payload не подтверждён — допустимы 3 стратегии через флаг YAML delete_strategy:

  1. noop_warn — удалить из state + AddWarning (дефолт для необратимых: ip_space);
  2. inverse — если есть «выключающие» значения в modify (напр. needEnableAVI:false);
  3. error — запретить destroy (AddError), если откат критичен.

Обратный payload — та же modify с выключающими значениями. Для ip_space его нет → только noop_warn.

4. Idempotency + ID

  • ID = идентичность (родитель + имя модификатора) = instanceUID:modifierName. Это правильно и не должен меняться per-apply. opUid в ID не класть (иначе replace). opUid — только в лог/приватный state.
  • Двойная аллокация (класс E) защищается не ID, а идемпотентностью modify: pre-check «desired == current» → пропустить run. Для ip_space перед modify читать state_params; если целевое достигнуто — skip.

5. Связь с родителем

  • <service>_id — ссылка на родителя (Required, уже так). depends_on не нужен — пользователь передаёт UUID.
  • Borrow state не нужен: Read тянет state_params родителя по UUID.
  • Родителя нет (ShouldRemoveFromState) → модификатор удаляется из state (уже есть).

6. Create vs Update

Единый reconcile(ctx, plan); Create и Update вызывают его (устраняет дубль веток).

7. Полный перечень кейсов (13 шт)

# Кейс Поведение
1 create родителя → create модификатора reconcile, полный payload
2 изменение одного поля полный payload, соседние не сбрасываются (A)
3 partial params досылка live→default→skip (B)
4 integer > 0 без значения/дефолта пропустить (не слать "0")
5 is_modifiable:true (needEnableAVI) не CreateOnly, менять без replace (C)
6 destroy модификатора по delete_strategy (D)
7 replace/taint reconcile + idempotency pre-check (E)
8 повторный apply без изменений desired==current → skip
9 родитель удалён remove из state
10 API не вернул код в state_params unknown→null (уже)
11 два модификатора разных типов разные ID
12 operation in progress waitForInstanceIdle (уже)
13 drift на платформе Read → план показывает изменение

Сводка мест правки

Место Правка
modifier.go:92 убрать CompactParams → прямой map (п.2)
modifier.go:77 единый reconcile() (п.6)
modifier.go:156 delete_strategy (п.3)
modifier.go:100 ID = instanceUID:modifierName (п.4)
RunOperationByCodeWithTimeout / reconcile idempotency pre-check (п.4,7)
params.go:116 учитывать modifier-канал/is_modifiable (класс C, кейс 5)
loader модификаторов YAML-поля delete_strategy, idempotency
operation_run_bycode.go оставить как есть (guard корректен)

Открытые вопросы к Opus (не закрыты ответом)

  1. Где брать значения для inverse-стратегии Delete? Для network «выключающие» значения — это хардкод per-modifier? Как их задать декларативно в YAML, без хардкода в генераторе?
  2. Формат YAML новых полей. Точная схема delete_strategy и idempotency: enum-значения, дефолты, валидация (fail-fast на неизвестных).
  3. Pre-check «desired == current» — где читать current? Через RefreshResourceState/state_params или отдельный GET? Как сериализовать сравнение для map-fixed/array-map-fixed (порядок ключей)?
  4. Как пометить модификатор «idempotency: check_before_run» на уровне YAML (а не хардкодом в коде reconcile)?
  5. Что если желаемое == текущее, но была «частичная» ошибка ранее — пропускать run безопасно всегда, или есть исключения?

Ответы Opus №2 (уточнения по 5 вопросам)

1. inverse-Delete — только декларативно в YAML, хардкод запрещён

Обратный payload зависит от параметров: needEnableAVI:false валиден, а virtualServicesCount (integer > 0) обнулить нечем → 0 невозможен. Значит inverse-значения задаются явным блоком в YAML. Если хоть один параметр не имеет валидного inverse — стратегия inverse недопустима (fail-fast в загрузчике). Для ip_space inverse нет вообще → только noop_warn.

2. Точная схема YAML новых полей

operations:
  - kind: modifier
    modifier: network
    action: modify
    delete_strategy: noop_warn      # enum: noop_warn | inverse | error
    idempotency: check_before_run   # enum: none | check_before_run
    delete_params:                  # обязателен ТОЛЬКО при delete_strategy: inverse
      - code: needEnableAVI
        value: "false"
    params: [...]

Go-контракт (OperationSpec):

DeleteStrategy string      `yaml:"delete_strategy,omitempty"` // "" → noop_warn
Idempotency    string      `yaml:"idempotency,omitempty"`     // "" → none
DeleteParams   []ParamSpec `yaml:"delete_params,omitempty"`

Дефолты: delete_strategy → noop_warn; idempotency → none. Fail-fast в ValidateSpec: значение вне enum → ошибка; inverse с пустым delete_params → ошибка; delete_params.code нет в params → ошибка; inverse-значение нарушает constraint параметра → ошибка на этапе генерации. GenModifier получает DeleteStrategy, Idempotency, DeleteParams.

3. Откуда читать current + как сравнивать

Читать из state_params, отдельный GET не делать (это уже источник истины для Read; второй источник = риск рассогласования).

Сравнение по типу:

Тип Как сравнивать
bool/int/string равенство после normalizeUniversalValueV6
map-fixed JSONStringsEquivalent (игнор порядка ключей)
array-map-fixed deep-equal с сохранением порядка элементов (порядок значим)

Порядок ключей map-fixed — нормализовать (не значим). Порядок элементов array-map-fixed — НЕ нормализовать (значим).

4. idempotency декларативно

Поле idempotency на modify-операции в YAML → ValidateSpec → GenModifier.Idempotency → шаблон modifier.go в reconcile() эмитит pre-check {{- if eq .Idempotency "check_before_run" }}. Для ip_space — в YAML; для остальных — дефолт none.

5. Когда безопасно skip run при desired == current (НЕ всегда)

Три условия безопасного skip:

  1. Инстанс idle — если pending/in-progress, сначала waitForInstanceIdle, потом перечитать state_params (иначе mid-flight аллокация даст ложное «уже равно»).
  2. current из живого state_params, НЕ из TF-state — после частичной ошибки TF-state может врать, а state_params отражает реальную платформу.
  3. Сравнение по всем полям, не по одному — skip только при совпадении ВСЕХ полей.

Итог:

idle? нет → wait, re-read
всё-live == всё-desired? да → skip run
иначе → reconcile (полный payload)