24 Commits
Author SHA1 Message Date
Repinoid 721c3fcfab feat(stand/FullPipe): орг organ (org_uid) + ресурсы-модификаторы — аллокация IP после эджа, затем SNAT 2026-09-24 13:48:53 +03:00
Repinoid e6675be906 docs: DEV 2.0.19 залит (nubes-dev) — правки по ревью ресурсов-модификаторов 2026-09-24 10:57:10 +03:00
Repinoid ed4493c0ee docs: правки по ревью (канонизация, destroy-семантика) + статус выполнения 2026-09-24 10:52:51 +03:00
Repinoid 1236c59e18 test(provider): тесты канонизации vIPConfigure (jsonencode-форма, пробелы, [{}], невалидный JSON) 2026-09-24 10:52:36 +03:00
Repinoid 4b497e61db fix(provider): nsxt_snat — не писать null в Required-атрибут, ошибки API в Delete → error, валидация пустого ip_space_name 2026-09-24 10:52:36 +03:00
Repinoid ba6c4f5122 fix(provider): канонизирующий plan-modifier для vip_configure (jsonencode сортирует ключи → вечный diff); не писать null в Required; Delete: ошибки API → error 2026-09-24 10:52:36 +03:00
Repinoid 3374bf4e08 docs(prompts): ответ Opus на ревью кода ресурсов-модификаторов (блокеры: порядок ключей, Required+null) + список правок 2026-09-24 10:51:10 +03:00
Repinoid 648db99628 docs(prompts): промпт на ревью Opus — полный код двух ресурсов-модификаторов, известный баг и вопросы 2026-09-24 10:49:15 +03:00
Repinoid 9412106e3f docs: DEV 2.0.18 залит (nubes-dev) — ресурсы-модификаторы vc_org_ip_allocation и vc_nsxt_snat 2026-09-24 10:34:02 +03:00
Repinoid 6e6d223c22 docs(plans): §13 — статус работ (сделано/ждёт команды) 2026-09-24 10:26:36 +03:00
Repinoid 62abcd64f5 docs(providers): страница ресурсов-модификаторов (nubes_vc_org_ip_allocation, nubes_vc_nsxt_snat) + nav 2026-09-24 10:26:21 +03:00
Repinoid 3973f912fc chore(docs): удалить docs/TODO/what_not_in_terraform.md + убрать ссылки на него из комментариев 2026-09-24 10:24:13 +03:00
Repinoid 73a7459a38 feat(provider): регистрация ресурсов-модификаторов vc_org_ip_allocation и vc_nsxt_snat 2026-09-24 10:22:17 +03:00
Repinoid 80d82a145a feat(provider): ресурс nubes_vc_nsxt_snat (modify ipSpaceName, inverse no-needed) 2026-09-24 10:22:17 +03:00
Repinoid 22cf2595ee feat(provider): ресурс nubes_vc_org_ip_allocation (modify vIPConfigure, uid орги) + тесты нормализации 2026-09-24 10:22:17 +03:00
Repinoid 574e300476 docs(plans): §12 — орга делается руками в ЛК, в tf только uid; правка генератора не блокер, нужны только 2 ресурса 2026-09-24 10:10:55 +03:00
Repinoid cb8389c17f docs(plans): §11 — ответы Opus раунд 3 (критерий отбора = явный список в конфиге генератора, релиз A только (б), Deprecated вместо падения) 2026-09-24 10:04:25 +03:00
Repinoid 664f04eb49 docs(plans): §10 — вопрос Опусу про безопасность универсальной правки графа генератора (5 сервисов с modify-only) 2026-09-24 10:00:57 +03:00
Repinoid 602b27ee1a docs(plans): ревью Opus по плану — §9 (ответы на 5 вопросов, count строкой, обязательный follow-up по генератору) 2026-09-24 09:55:02 +03:00
Repinoid 97d5ca818e docs(plans): план двух ресурсов-модификаторов (nubes_vc_org_ip_allocation, nubes_vc_nsxt_snat) + вопросы на ревью 2026-09-24 09:32:55 +03:00
Repinoid bccf8f7320 docs(notes): раунд 2 Q&A с Opus (владелец параметра, массив vs элемент, keep_on_destroy, deprecated-переход, тип атрибута) 2026-09-24 09:31:10 +03:00
Repinoid 129dab97a0 docs(notes): Q&A с Opus по дизайну ресурсов-модификаторов + замечания к ответам 2026-09-24 09:29:22 +03:00
Repinoid 75700a92da docs(notes): исправлен ложный факт «схема только из create» в CHAT_RESUME_IAC (loader.go:96 мержит create+modify) 2026-09-24 08:48:54 +03:00
Repinoid 51ff9b3751 docs(notes): разбор fresh-create HAR — state после create (vIPConfigure=[{}], ipSpaceName только в modify) 2026-09-24 08:48:54 +03:00
16 changed files with 2167 additions and 50 deletions
+57
View File
@@ -0,0 +1,57 @@
# =============================================================================
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
#
# Порядок строго такой:
# орга (создана вручную в ЛК)
# -> nubes_vc_vdc.vdc
# -> nubes_vc_nsxt.edge
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
#
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
# созданный vDC и Edge. Иначе modify на орге падает
# («Can't cast Complex Object Type Struct to String»).
# =============================================================================
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" {
org_uid = var.org_uid
vip_configure = jsonencode([
{
name = var.ip_space_name
count = var.ip_count
}
])
# false = при destroy отправить обратный modify с count=0 (квота обнулится)
keep_on_destroy = false
depends_on = [nubes_vc_nsxt.edge]
}
# 2. SNAT на эдже (modify: ipSpaceName)
resource "nubes_vc_nsxt_snat" "snat" {
nsxt_uid = nubes_vc_nsxt.edge.id
ip_space_name = var.ip_space_name
keep_on_destroy = false
# ipSpace должен быть уже выделен на организации
depends_on = [nubes_vc_org_ip_allocation.org_ip]
}
output "allocated_org_ip" {
description = "Выделено внешних IP на организации"
value = {
org_uid = var.org_uid
ip_space_name = var.ip_space_name
ip_count = var.ip_count
}
}
output "snat_ip_space" {
description = "ipSpace, включённый как SNAT на эдже"
value = nubes_vc_nsxt_snat.snat.ip_space_name
}
+19
View File
@@ -16,6 +16,25 @@ variable "organization" {
description = "Имя или UUID организации (vc_org)"
}
# UUID той же организации — нужен ресурсам-модификаторам (они адресуются строго по uid)
variable "org_uid" {
type = string
description = "UUID организации (vc_org) для nubes_vc_org_ip_allocation"
}
# --- Модификаторы (IP на орге + SNAT на эдже) ---
variable "ip_space_name" {
type = string
description = "Имя ipSpace, доступное организации (смотреть в ЛК, напр. internet-ipv4-v1)"
}
variable "ip_count" {
type = string
default = "3"
description = "Сколько внешних IP выделить на организации (count — строка)"
}
variable "vdc_resource_name" {
type = string
default = "fullpipe-vdc"
+1 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "2.0.17"
version = "2.0.19"
}
}
}
@@ -0,0 +1,227 @@
# ПЛАН: два ресурса-модификатора для цепочки Штурвала (2026-09-24)
> Статус: **план, не реализовано**. Отправляется на ревью Opus.
> Решения приняты пользователем: 2 ресурса сейчас, универсальность потом; орга — не наша (адресация по uid);
> «один ресурс = весь массив `vIPConfigure`»; тип атрибута — String+JSON; apply — только пользователь.
## 1. Цель
Дать клиенту возможность собрать цепочку **одним `apply`**:
```
nubes_vc_org (вне state, адресация по uid)
nubes_vc_vdc → nubes_vc_nsxt
nubes_vc_org_ip_allocation (modify 662, vIPConfigure) ← новый ресурс
nubes_vc_nsxt_snat (modify 372, ipSpaceName) ← новый ресурс
nubes_k8s_shturval_cluster
```
Сейчас это невозможно: `Create` не отправляет modify-only параметры, а `Update` — второй прогон.
## 2. Вне scope
- Универсальный механизм (реестр модификаторов, генераторные метки) — потом.
- `vcExternalIp` — не разбирали.
- Правка генератора по modify-only (см. §7) — отдельный этап, требует решения.
## 3. Ресурс 1 — `nubes_vc_org_ip_allocation`
| | |
|---|---|
| Файл | `provider/internal/resources_core/org_ip_allocation_resource.go` (новый, hand-written) |
| Регистрация | `provider/internal/provider/provider.go`, `Resources()` (рядом с `NewServiceOperationResource`) |
| Атрибуты | `org_uid` — String, Required; `vip_configure` — String (JSON `[{"name":..,"count":..}]`), Required, нормализация JSON как в `resources_core/json_planmodifier.go`; `keep_on_destroy` — Bool, Optional, default `false` |
| ID | `org_uid` (один ресурс на оргу; массив целиком) |
| Create/Update | `modify` на инстансе орги: `vIPConfigure` = JSON-массив целиком (replace-семантика). Путь: `core.RunInstanceOperationUniversalByCode` (или обёртка `resources_core`), под `LockInstance(org_uid)` |
| Read | `core.GetInstanceStateParams(org_uid)` → ключ `vIPConfigure`; пустое/`[{}]`/`count=0` → нормализовать; родитель 404/deleted → `RemoveResource` (`resources_core.ShouldRemoveFromState`). **Нужен нормализующий planmodifier** (аналог JSON-модификатора), иначе вечный дрейф при плановом 3→0 (ревью Opus, п.3) |
| Delete | `keep_on_destroy=true` → no-op + Warning. Иначе: родитель жив → modify с `count="0"` по каждому элементу (**строкой**, как в HAR; форма проверена тестом 09-22) + Warning; родитель мёртв → no-op + Warning. Массив `[]` НЕ отправлять — не проверен (ревью Opus, п.2) |
| Import | passthrough по `org_uid` |
## 4. Ресурс 2 — `nubes_vc_nsxt_snat`
| | |
|---|---|
| Файл | `provider/internal/resources_core/nsxt_snat_resource.go` (новый) |
| Атрибуты | `nsxt_uid` — String, Required; `ip_space_name` — String, Required (`no-needed` = SNAT выключен, канон из HAR); `keep_on_destroy` — Bool, Optional, default `false` |
| ID | `nsxt_uid` |
| Create/Update | `modify` 372 = `ip_space_name`. Отправляется **только** 372 (остальные досыпаются из live — проверить, см. §8 вопрос 1) |
| Read | live `ipSpaceName` из `state.params`; отсутствует или `no-needed` → null; родитель мёртв → `RemoveResource` |
| Delete | inverse: `modify` с `ipSpaceName = "no-needed"` (канон, подтверждён HAR) |
| Import | passthrough по `nsxt_uid` |
## 5. Зависимости и порядок
```
nubes_vc_nsxt → nubes_vc_org_ip_allocation → nubes_vc_nsxt_snat → k8s cluster
```
- SNAT обязан зависеть от org-IP: имя ipSpace берётся из аллокации (ребра графа TF не видит — связь по имени).
- Destroy пойдёт обратно: cluster → SNAT (`no-needed`) → org-IP (`count=0`) → nsxt → vdc.
- Инвариант: destroy модификаторов **не трогает** саму оргу.
## 6. Этапы работ (последовательность)
1. **Проверка по коду** (чтение): приоритет live→paramValue→default при дозаполнении параметров; `instance.go:478` (что именно эмитит Update).
2. `nubes_vc_org_ip_allocation` + регистрация + unit-тесты (нормализация JSON, чтение `[{}]`, Delete-ветки).
3. `nubes_vc_nsxt_snat` + регистрация + unit-тесты.
4. Общие хелперы в `resources_core` (если дублируются).
5. Живой прогон на dev (**apply — пользователь**): `FullPipe`, орга **saas** (`organization_type = "saas"`, иначе коллизия имени `WZ03709-iaas`).
6. Проверки после прогона: `plan` чистый (нет дрейфа), SNAT включён в одном apply, `destroy` не падает.
7. Документация: `HOW_TO/`/`docs/`, `VERSIONS.md`, коммиты по смыслу.
## 7. Отдельный этап (требует решения): генератор
Причина — инцидент: создание `nubes_vc_org` с `v_ip_configure` даёт `inconsistent result after apply`
(платформа после create отдаёт `vIPConfigure: [{}]`, read-back перекрывает план).
Минимальные правки генератора (по Opus):
- **а)** modify-only параметр → **Optional+Computed** + `Deprecated` + не отправлять в `Update` (переход без breaking; удаление атрибута — только в следующем major);
- **б)** исключить modify-only поля из **create-read-back** (`InputField`).
**Не реализуем в этом этапе** — ждём решения пользователя (правка генератора задевает все сервисы).
> ⚠️ По ревью Opus (2026-09-24) пункт **§7б — обязательное условие**, а не опциональное:
> без исключения modify-only из create-read-back при переходном варианте будет борьба за поле
> между instance-ресурсом и модификатором. Пункт остаётся обязательным follow-up.
>
> 📌 Раунд 3: §7б выделяется в **отдельный релиз A** (универсально, схема не меняется, non-breaking,
> полностью закрывает инцидент `inconsistent result` на create vc_org). Пункты §7а + §7в — **релиз B**
> вместе с новыми ресурсами.
## 8. Вопросы для ревью Opus
1. Верно ли, что `RunInstanceOperationUniversalByCode` дозаполняет незаданные параметры из **live `state.params`**
(а не из дефолтов формы)? Если да — SNAT-ресурс может шлать только 372. Если нет — нужен явный pre-read+merge.
2. Delete для «весь массив»: слать `[{name, count:"0"}]` (проверено тестом) или `[]` (не проверено)? Что безопаснее
и не оставит ли `[]` элемент в state платформы?
3. Read-нормализация: считать ли `count="0"` и `[{}]` одним состоянием «пусто»? Не даст ли это ложный дрейф
при плановом уменьшении 3 → 0?
4. Переходный вариант (Deprecated + Optional+Computed, instance больше не шлёт параметр): не появится ли дрейф,
когда значение выставил модификатор, а instance-ресурс его только читает?
5. Достаточно ли `depends_on` (SNAT → org-IP) для корректного destroy, если org-IP-модификатор должен
уничтожиться **до** эджа? Нужны ли дополнительные рёбра?
---
## 9. Ревью Opus (2026-09-24, отдельный чат)
**Вердикт фактуры:** оба документа (план и `HAR_FRESH_CREATE_2026-09-24.md`) проверены по коду — факты верны,
ссылки на пути точны.
**Ответы на вопросы §8:**
1. **Подтверждено кодом.** `operation_run_bycode.go:108-142` дозаполняет все незаданные параметры по приоритету
**live `state.params` → `paramValue` формы → `defaultValue`**; если ничего нет — параметр пропускается.
SNAT-ресурс может шлать только 372, pre-read+merge НЕ нужен.
2. Слать `[{name, count:"0"}]`. `[]` не проверен, риск пустого payload/reset.
3. `count="0"`, `[{}]`, пустой массив — одно состояние «пусто» при Read. Иначе `[{}]` после create даёт ложный
дрейф; и для случая 3→0 нужен нормализующий planmodifier.
4. **Дрейф возможен** в переходном варианте (борьба за поле с read-back instance-ресурса) → §7б обязателен.
5. `depends_on` достаточно: TF развернёт граф, SNAT уничтожится до org-IP. Доп. рёбер не нужно при условии,
что оба модификатора зависят от `nubes_vc_nsxt`, а кластер — от SNAT.
**Замечания кодеру:**
- Два новых ресурса **не закрывают** инцидент `inconsistent result` на `nubes_vc_org` (Required-поле остаётся):
§7 — обязательный follow-up, не «потом».
- `count` в payload — **строка** `"0"` (в HAR всегда строка); зафиксировать тип явно.
- Стенд: орга **`saas`**, иначе коллизия `WZ03709-iaas`.
**Фиксатор:** эпоха `kind: modifier` отменена — ветку не переиспользовать; новые ресурсы hand-written
в `resources_core`, без реестра модификаторов.
---
## 10. Раунд 3 — вопрос Опусу: «это не поломает ничего?» (составлен 2026-09-24)
**Контекст (факт).** Правка шаблона `templates/instance.go` действует на все ресурсы. Замер по
`generated/dev/resources_yaml/*.yaml`: modify-only параметры есть только у **5 сервисов** —
`19_vc_org` (`vIPConfigure`), `22_vc_nsxt` (`ipSpaceName`), `12_s3` (`maxBucketsPerUser`,
`maxObjectsPerBucket`, `maxSizeGbPerUser`), `90_postgres` (`refreshCert`), `109_zones_v2` (`records`).
Цель правки — только первые два; у остальных трёх это рабочие атрибуты `Update`.
**Вопросы:**
1. **Критерий отбора.** Предлагается признак в спеке (`owned_by_modifier: true`). Это доменная метка в
универсальном YAML, что противоречит прежнему канону «YAML без доменных меток». Какой критерий корректен
в вашей архитектуре: spec-флаг, «required только в modify» (тогда ловится `vIPConfigure`, но **не**
`ipSpaceName` — он `required: false`), или явный список в генераторе?
2. **Безопасность (б)** (исключить modify-only из create-read-back): безопасно ли это для всех 5 сервисов,
или у s3/postgres/zones read-back нужен (иначе drift/потеря значения в state)?
3. **Поведение для существующих конфигов.** У тех, кто уже пишет `v_ip_configure`/`ip_space_name` в `.tf`,
после (в) модификация молча перестанет отправляться. Правильно ли молчание, или нужно явное падение
(ошибка «параметр управляется ресурсом `…ip_allocation`») — и как это сделать, если схема общая?
4. **Снятие Required у 5 сервисов** — не ломает ли `UseStateForUnknown`/JSON-planmodifier и не порождает
ли drift у тех, у кого поле было обязательным и уже заполнено?
5. **Порядок релиза.** Правильно ли разводить: релиз A — только (б) (чинит create орги, ничего больше
не трогает), релиз B — (а)+(в) вместе с новыми ресурсами-модификаторами?
---
## 11. Ответы Opus (раунд 3)
1. **Критерий отбора.** Структурный признак «modify-only = есть в `modifyParams`, нет в `createParams`»
(симметрично `ComputeCreateOnly`) — факт спеки, но он ловит **все 5** сервисов и не отличает
«управляется модификатором» от «рабочий Update-атрибут». «Required только в modify» неполон
(пропускает `ipSpaceName`, `required:false`). **Автопризнака не существует — это доменное знание.**
`owned_by_modifier: true` в пер-сервисном YAML — отвергнуть (нарушает канон);
правильно — **явный список в конфиге генератора**.
2. **Безопасность (б): безопасно для всех 5.** Read-back в create кладёт в state пост-create дефолт
(`[{}]`), которого юзер не задавал — это и есть источник `inconsistent result`. Create их и так не шлёт.
**Steady-state Read и Update read-back их по-прежнему перечитывают**, поэтому дрейф не теряется;
(б) убирает только бессмысленную перезапись сразу после create. s3/postgres/zones не страдают.
3. **Существующие конфиги.** Жёстко падать нельзя (схема общая, «владелец» — доменное знание, сломает state).
Правильно — `Deprecated` с текстом «управляется ресурсом `…ip_allocation`» → warning на каждом plan.
Молчаливое прекращение отправки — плохой UX, не делать. Удаление атрибута — только в следующий major.
4. **Снятие Required.** Затрагивает только `vIPConfigure` (`ipSpaceName` уже Optional).
`Optional+Computed` — штатный безопасный переход; `UseStateForUnknown` гасит unknown и drift не создаёт;
у заполненных полей значение удержится через read-back. Борьба за поле снимается (б)+(в).
5. **Порядок релиза — подтверждён:**
- **A — только (б):** универсально, схема не меняется, non-breaking, **полностью закрывает** инцидент
`inconsistent result` на create `nubes_vc_org`; s3/postgres/zones не трогает.
- **B — (а)+(в) + новые ресурсы** (Deprecated на delegated-параметры, отцеп от read-back/send).
**Следствие для наших решений:** критерий «кто делегируется» задаётся явным списком в конфиге генератора;
работа разбивается на релиз A (маленький, безопасный) и релиз B (ресурсы + отцепка).
---
## 12. ⚠️ Уточнение пользователя (2026-09-24): оргу делаем РУКАМИ в ЛК
**Факт:** орга создаётся вручную в ЛК и **в Terraform не заводится** — она одна на всё.
В tf она используется только как uid для модификаций.
**Что это меняет:**
1. `nubes_vc_org` в конфигурации **не используется** → дефект «`inconsistent result after apply` при create орги»
для этой задачи **не блокер** (остаётся латентным дефектом ресурса).
2. **Релиз A (правка create-read-back) становится необязательным** для цепочки Штурвала.
3. `nubes_vc_nsxt`: править генератор **тоже не нужно** — достаточно **не задавать** `ip_space_name` в `.tf`.
Атрибут Optional+Computed: SNAT выставит модификатор, read-back подхватит значение в state, дрейфа не будет.
4. Итог: для задачи нужны **только два новых ресурса** (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`),
оба адресуются по uid родителя. Правки генератора (§7, релизы A/B) — **отдельная тема**, не вход в эту работу.
**Открытый вопрос:** эдж (`nubes_vc_nsxt`) создаётся Terraform или тоже руками? На состав работ не влияет
(в обоих случаях нужны те же два ресурса), влияет только на пример конфигурации.
---
## 13. Статус работ (обновлено 2026-09-24)
**Сделано:**
- ✅ Проверка по коду: `RunInstanceOperationUniversalByCode` дозаполняет незаданные параметры
(live → paramValue → default) — частичный payload безопасен.
- ✅ `nubes_vc_org_ip_allocation` — `provider/internal/resources_core/org_ip_allocation_resource.go`
(коммит `22cf259`) + unit-тесты нормализации (`[{}]` → «пусто»).
- ✅ `nubes_vc_nsxt_snat` — `provider/internal/resources_core/nsxt_snat_resource.go` (коммит `80d82a1`).
- ✅ Регистрация в `provider/internal/provider/provider.go` (коммит `73a7459`).
- ✅ Пример конфигурации: `tf_examples/modify_resources/` (README, `main.tf`, `terraform.tfvars.example`).
⚠️ Каталог `tf_examples/` в `.gitignore:16` — пример локальный, как и остальные примеры в этом каталоге.
- ✅ Публичная страница: `docs/curated/modifiers/org_ip_and_snat.md` + nav (коммит `62abcd6`).
- ✅ Ветка-снимок состояния: `save/state-before-modify-resources-2026-09-24`.
- ✅ `go build` / `go vet` / `go test ./...` — зелёные.
**Не сделано (ждёт команды пользователя):**
- ⏳ Живой прогон на dev (`FullPipe`, орга `saas`; `apply` — только пользователь).
- ⏳ Бамп версии провайдера, сборка и заливка (`TOOLS/scripts/03_build_and_upload_provider.sh`).
- ⏳ Публикация документации (`04_build_and_publish_docs.sh`).
- ⏳ Решение по правке генератора (релизы A/B, §7) — отдельная тема.
@@ -0,0 +1,821 @@
# Ревью Opus: два новых ресурса-модификатора (2026-09-24)
> Что приложено: полный код двух новых ресурсов, тестов, фрагмент регистрации, известные проблемы и вопросы.
> Репо: `tf_provider`, коммиты `22cf259`, `80d82a1`, `73a7459`. Провайдер DEV `2.0.18` собран и залит.
> **Просьба: ревью полное, включая то, что я не вижу. Код не писался под ревью — можно предлагать переписать.**
## 1. Контекст
- Организация Cloud Director (сервис 19) и сетевой шлюз периметра (сервис 22) создаются **вручную в ЛК**.
В Terraform их нет — адресуются по `uid`.
- В схемах `nubes_vc_org` / `nubes_vc_nsxt` modify-параметры **есть** (генератор мержит create+modify),
но `Create` их не отправляет → в одном `apply` цепочку не собрать. Поэтому сделаны два отдельных ресурса,
которые делают только `modify`.
- Орга и эдж — единственные ресурсы своего типа (одна орга на realm, один эдж на vDC).
## 2. Известный баг (найден после заливки, ещё НЕ исправлен)
`formatVipConfigure` (файл 1, строка 348) собирает `{"name":…,"count":…}`.
Terraform `jsonencode` сортирует ключи по алфавиту:
```
$ terraform console
> jsonencode([{name="internet-ipv4-v1", count="3"}])
"[{\"count\":\"3\",\"name\":\"internet-ipv4-v1\"}]"
```
`JsonNormalize` (приложен ниже) только компактит JSON, порядок ключей не меняет.
→ план (`count,name`) ≠ state после Read (`name,count`) → **вечный diff**.
## 3. Риски, которые я не могу проверить без живой платформы
1. `vip_configure` и `ip_space_name` — **Required**, а `Read` может вернуть `null` («аллокации нет»).
Корректно ли это для Required-атрибута (не будет ли ошибки/вечного diff)?
2. `Update` **не делает read-back** после modify — не приведёт ли это к inconsistent result / дрейфу.
3. Имена live-ключей (`vIPConfigure`, `ipSpaceName`) взяты из HAR ЛК, не сверены с кодом.
4. `setSnat`: пустая строка молча заменяется на `no-needed` (скрытое поведение).
5. CRUD живым прогоном **не проверялся вообще** — только `go build`/`vet`/юнит-тесты парсинга.
## 4. Приложенный код
### 4.1. `provider/internal/resources_core/org_ip_allocation_resource.go`
```go
package resources_core
import (
"context"
"encoding/json"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &OrgIpAllocationResource{}
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
//
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
// и адресуется здесь по uid.
//
// Семантика операции — replace всего массива: переданное значение полностью заменяет
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
type OrgIpAllocationResource struct {
client *core.UniversalClient
}
type OrgIpAllocationModel struct {
ID types.String `tfsdk:"id"`
OrgUID types.String `tfsdk:"org_uid"`
VIPConfigure types.String `tfsdk:"vip_configure"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
type vipAllocation struct {
Name string
Count string
}
func NewOrgIpAllocationResource() resource.Resource {
return &OrgIpAllocationResource{}
}
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
}
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"org_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"vip_configure": schema.StringAttribute{
Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка.",
PlanModifiers: []planmodifier.String{
JsonNormalize(),
},
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
"`count=0` по каждому элементу).",
},
},
}
}
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
// Организации больше нет — ресурс тоже не нужен.
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
raw, ok := live["vIPConfigure"]
if !ok {
// Платформа не вернула параметр — считаем, что аллокации нет
// (у свежей орги ключ присутствует со значением `[{}]`, что тоже «пусто»).
state.VIPConfigure = types.StringNull()
} else {
items, parseErr := parseVipConfigure(raw)
if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return
}
if len(items) == 0 {
state.VIPConfigure = types.StringNull()
} else {
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
}
}
state.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("не удалось проверить существование организации %s: %s", orgUID, err),
)
return
}
if remove {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
)
return
}
unlock := r.client.LockInstance(orgUID)
defer unlock()
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
items := []vipAllocation{}
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
)
return
}
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
zero := make([]vipAllocation, 0, len(items))
for _, item := range items {
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
}
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(zero),
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"Квота IP обнулена",
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
)
}
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("org_uid"), uid)...)
}
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID.ValueString())
if uid == "" {
return fmt.Errorf("org_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
items, err := parseVipConfigure(vipConfigure.ValueString())
if err != nil {
return err
}
if len(items) == 0 {
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
}
unlock := r.client.LockInstance(uid)
defer unlock()
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(items),
})
}
// parseVipConfigure разбирает значение параметра vIPConfigure.
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
func parseVipConfigure(raw string) ([]vipAllocation, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
}
out := make([]vipAllocation, 0, len(items))
for _, item := range items {
name := ""
if v, ok := item["name"]; ok && v != nil {
name = strings.TrimSpace(fmt.Sprint(v))
}
if name == "" {
continue
}
count := "0"
if v, ok := item["count"]; ok && v != nil {
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
count = parsed
}
}
out = append(out, vipAllocation{Name: name, Count: count})
}
return out, nil
}
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
// (порядок ключей как в HAR; count — строка).
func formatVipConfigure(items []vipAllocation) string {
if len(items) == 0 {
return "[]"
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count)) // ← строка 348, ИСТОЧНИК БАГА
}
return "[" + strings.Join(parts, ",") + "]"
}
```
### 4.2. `provider/internal/resources_core/nsxt_snat_resource.go`
```go
package resources_core
import (
"context"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &NsxtSnatResource{}
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
//
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
//
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
type NsxtSnatResource struct {
client *core.UniversalClient
}
type NsxtSnatModel struct {
ID types.String `tfsdk:"id"`
NsxtUID types.String `tfsdk:"nsxt_uid"`
IpSpaceName types.String `tfsdk:"ip_space_name"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// noNeededIpSpace — каноническое значение «SNAT не нужен».
const noNeededIpSpace = "no-needed"
func NewNsxtSnatResource() resource.Resource {
return &NsxtSnatResource{}
}
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
}
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
"Значение `no-needed` выключает SNAT.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"nsxt_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ip_space_name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
"`ipSpaceName = \"no-needed\"`).",
},
},
}
}
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// Ключа ipSpaceName нет, пока SNAT ни разу не включали (HAR fresh-create),
// поэтому отсутствие ключа = null. Значение "no-needed" (SNAT выключен) — реальное.
if raw, ok := live["ipSpaceName"]; !ok || strings.TrimSpace(raw) == "" {
state.IpSpaceName = types.StringNull()
} else {
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
}
state.ID = types.StringValue(nsxtUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("не удалось проверить существование шлюза %s: %s", nsxtUID, err),
)
return
}
if remove {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
)
return
}
unlock := r.client.LockInstance(nsxtUID)
defer unlock()
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
"ipSpaceName": noNeededIpSpace,
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"SNAT выключен",
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
)
}
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
}
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
uid := strings.TrimSpace(nsxtUID.ValueString())
if uid == "" {
return fmt.Errorf("nsxt_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
value := strings.TrimSpace(ipSpaceName.ValueString())
if value == "" {
value = noNeededIpSpace
}
unlock := r.client.LockInstance(uid)
defer unlock()
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
// а не с live-состоянием инстанса — можно ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"ipSpaceName": value,
})
}
```
### 4.3. `provider/internal/resources_core/org_ip_allocation_test.go`
```go
package resources_core
import "testing"
func TestParseVipConfigure_EmptyAndBroken(t *testing.T) {
cases := []struct {
name string
raw string
want int
}{
{"пустая строка", "", 0},
{"пустой массив", "[]", 0},
{"пустой элемент (свежая орга)", "[{}]", 0},
{"только name без count", `[{"name":"internet-ipv4-v1"}]`, 1},
{"элемент без name", `[{"count":"3"}]`, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseVipConfigure(tc.raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != tc.want {
t.Fatalf("получено %d элементов, ожидалось %d (%+v)", len(got), tc.want, got)
}
})
}
}
func TestParseVipConfigure_CountAsString(t *testing.T) {
got, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":4}]`)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != 1 || got[0].Count != "4" {
t.Fatalf("ожидался count=\"4\", получено %+v", got)
}
}
func TestFormatVipConfigure_Canonical(t *testing.T) {
got := formatVipConfigure([]vipAllocation{{Name: "internet-ipv4-v1", Count: "3"}})
want := `[{"name":"internet-ipv4-v1","count":"3"}]` // ← ожидание неверное: Terraform даёт count,name
if got != want {
t.Fatalf("получено %q, ожидалось %q", got, want)
}
if empty := formatVipConfigure(nil); empty != "[]" {
t.Fatalf("для пустого списка ожидалось \"[]\", получено %q", empty)
}
}
func TestParseVipConfigure_RoundTripIsStable(t *testing.T) {
raw := `[{"name":"internet-ipv4-v1","count":"4"}]`
items, err := parseVipConfigure(raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if again := formatVipConfigure(items); again != raw {
t.Fatalf("round-trip не стабилен: %q → %q", raw, again)
}
}
func TestParseVipConfigure_InvalidJSON(t *testing.T) {
if _, err := parseVipConfigure(`{"name":"x"}`); err == nil {
t.Fatal("ожидалась ошибка на объект вместо массива")
}
}
```
### 4.4. Регистрация — `provider/internal/provider/provider.go`
```go
func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource {
resources := resources_gen.AllResources()
resources = append(resources, resources_core.NewServiceOperationResource)
// Ресурсы-модификаторы для операций, которых нет в create-схеме ресурсов-инстансов.
// Организация и шлюз создаются вручную в ЛК, поэтому адресуются по uid, а не ссылкой на ресурс.
resources = append(resources, resources_core.NewOrgIpAllocationResource)
resources = append(resources, resources_core.NewNsxtSnatResource)
return resources
}
```
### 4.5. Существующий plan-modifier `JsonNormalize` (`resources_core/json_planmodifier.go`)
```go
// PlanModifyString сворачивает JSON до компактного вида.
// Если значение не является корректным JSON — оставляет как есть, не добавляет ошибку.
func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
if req.PlanValue.IsUnknown() || req.PlanValue.IsNull() {
return
}
raw := req.PlanValue.ValueString()
var buf bytes.Buffer
if err := json.Compact(&buf, []byte(raw)); err != nil {
return
}
resp.PlanValue = types.StringValue(buf.String())
}
```
## 5. Вопросы на ревью
1. **Как правильно закрыть баг порядка ключей** — (а) сортировать ключи в обоих местах (`count`,`name`);
(б) свой plan-modifier, канонизирующий ввод через parse→canonical, чтобы любой порядок от юзера сходился;
(в) отказаться от JSON-строки и сделать nested-атрибут (тогда `jsonencode` у юзера не нужен)?
Что правильно и что меньше ломает?
2. **`Required` vs `Optional+Computed`** для `vip_configure` / `ip_space_name`: `Read` может вернуть «пусто».
Корректно ли писать `null` в state для Required-атрибута, или это неверно и надо другой тип?
3. Нужен ли **read-back после Create/Update** (сейчас его нет)? Не приведёт ли отсутствие read-back
к inconsistent result или наоборот — к тому, что мы храним в state не то, что на платформе?
4. **Delete**: последовательность «`ShouldRemoveFromState` → `LockInstance` → `ByCode`» корректна?
Ошибки API при destroy — warning (как сейчас) или error?
5. **Идемпотентность**: сознательно не используем `ByIdempotent`, потому что его сравнение идёт с `paramValue`
формы, а не с live. Согласен, или есть другой способ не гонять лишний modify?
6. **Имена live-ключей** (`vIPConfigure`, `ipSpaceName`): где проверить, чтобы не полагаться на HAR?
7. **Что ещё в этом коде сломается**, чего я не вижу? Особенно: имена/семантика диагностик,
поведение `void`-возвратов, `RemoveResource` vs `RemoveResource`-в-Delete, импорт.
---
# 6. Ответ Opus на ревью (2026-09-24)
**Вердикт:** главный блокер — **баг порядка ключей + `Required` с `null`**. Оба чинятся
канонизирующим plan-modifier'ом. Всё остальное (Configure/Import/Lock/diagnostics) — корректно.
**По вопросам:**
1. **Баг порядка ключей → вариант (б):** plan-modifier, прогоняющий значение через
`parseVipConfigure → formatVipConfigure`. Сортировка ключей (а) не спасает: `jsonencode` юзера даст
`count,name`, а `formatVipConfigure` — `name,count`; минус только nested (в). Чинить и тест
`TestFormatVipConfigure_Canonical` (ожидание в нём неверное).
2. **`Required` + `null` в `Read` = источник `Provider produced inconsistent result`.** После apply
state обязан совпасть с планом. Правильно: **не писать `null`**, хранить конфиг-значение; либо делать
атрибут `Optional`, а не `Required`.
3. **Read-back не обязателен**, но **канонизация ввода обязательна** — иначе inconsistent-result при первом
`refresh` (там и всплывёт баг п.1).
4. **Delete:** последовательность `ShouldRemoveFromState → Lock → ByCode` корректна. Но ошибки API при destroy
должны быть **error, а не warning**: иначе реальный сбой обнуления квоты замалчивается, ресурс уходит из
state, квота висит. Warning — только для «родителя уже нет».
5. **Идемпотентность:** `ByCode` выбран правильно (`ByIdempotent` сравнивает с `paramValue` формы, ложно
пропустит modify).
6. **Имена live-ключей:** в коде провайдера их нет — только HAR; сверить можно исключительно живым
`GetInstanceStateParams` (прогон). Пока это риск, а не факт.
7. **Дополнительно:**
- `setSnat`: тихая подмена `""` → `no-needed` — заменить на валидацию (ошибку).
- `nsxt_snat.Read`: `no-needed` пишется в state как реальное значение — согласовать с решением п.2.
- `applyAllocation` при пустом массиве → error, значит «снять всё» через `vip_configure` нельзя
(только destroy) — **задокументировать** в описании атрибута.
- Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым.
## Требуется сделать (по итогам ревью) — ВЫПОЛНЕНО (коммиты `ba6c4f5`, `4b497e6`, `1236c59`)
1. ✅ Заменить `JsonNormalize()` на канонизирующий plan-modifier (`parse → formatVipConfigure`) —
закрывает баг порядка ключей (в т.ч. для `jsonencode`).
2. ✅ Убрать запись `null` в `Required`-атрибуты (`vip_configure`, `ip_space_name`) — при пустом live
сохраняется текущее значение state (проверка «что отправили — то и в state»).
3. ✅ `Delete`: ошибки API → `AddError`; warning оставлен только для отсутствующего родителя.
4. ✅ `setSnat`: вместо тихой подмены — валидация пустой строки.
5. ✅ Задокументировано: «снять всё» через `vip_configure` нельзя, только `destroy`.
6. ✅ Поправлены/добавлены тесты канонизации (`jsonencode`-форма, пробелы, `[{}]`, невалидный JSON).
7. ⏳ Новый релиз провайдера (2.0.19) с повторной заливкой в `nubes-dev`.
@@ -0,0 +1,78 @@
# HAR fresh-create: что происходит при создании орги/эджа (dev, 2026-09-24)
> Источники: `HAR/globak.har` (ЛК: создание орги + vDC + эджа, затем два modify),
> `HAR/org_already exists.har` (отказ создания орги из-за коллизии имени).
> Стенд: `lk-api-gateway-dev.ngcloud.ru`, realm `sandbox.nubes.ru`.
> Цель разбора: понять, что реально приходит в `state.params` после `create`
> (влияет на read-back в сгенерированных ресурсах).
## 1. Поток создания в ЛК
Инстанс создаётся **в два шага**, не одним запросом:
1. `POST /instances` — тело **только** `{"serviceId":N,"displayName":"…","descr":""}`. Никаких параметров.
2. `POST /instanceOperations` — `{"instanceUid":"…","operation":"create"}` → возвращает `instanceOperationUid`.
3. `POST /instanceOperationCfsParams` — по одному запросу на параметр: `{"paramValue":"…","instanceOperationUid":"…","svcOperationCfsParamId":NNN}`.
4. `GET /instanceOperations/{opUid}/validate-cfs`.
5. `POST /instanceOperations/{opUid}/run`.
6. Поллинг `GET /instanceOperations/{opUid}` до `dtFinish`.
Это в точности тот же набор эндпоинтов, что использует наш провайдер (`core/operation_run.go`, `operation_cfs.go`).
## 2. Параметры операций (из HAR)
| Сервис | Операция | Параметры |
|---|---|---|
| Орга (19) | create | `418 resourceRealm=sandbox.nubes.ru`, `556 organizationType`, `1125 orgSuffix` |
| vDC (21) | create | `30`, `746`, `335`, `397`, `557`, `558`, `361` (+ `8` = uid орги) |
| Эдж / vc_nsxt (22) | create | `621 vdcType=vdc`, `8 vdcUid`, `622`, `340 needEnableAVI`, `341 virtualServicesCount`, `825 qosProfile`, `1110 routedNetConfiguration` |
| Орга (19) | modify (207) | `662 vIPConfigure = [{"name":"internet-ipv4-v1","count":"3"}]` |
| Эдж (22) | modify (111) | `368 needEnableAVI`, `369 virtualServicesCount=4`, `856 qosProfile`, **`372 ipSpaceName=internet-ipv4-v1`**, `1112 routedNetConfiguration` |
`372 ipSpaceName` **не участвует в create** — только в modify. Ровно как в нашем `Update`
(`22_vc_nsxt_resource.go`), который шлёт 368/369/372/856/1112.
## 3. `state.params` до и после modify
Ответ `GET /instances/{uid}`: параметры лежат в **`instance.state.params`**
(`instance.params` = `null`). Наш `GetInstanceStateParams` (`core/instance_params.go:35-45`)
читает именно этот путь — то есть read-back их видит.
| Инстанс | Сразу после create | После modify |
|---|---|---|
| Орга `df5ec5f2…` («kontra») | `{"admins":[], "vIPConfigure":[{}], "resourceRealm":"sandbox.nubes.ru", "organizationType":"saas"}` | `vIPConfigure=[{"name":"internet-ipv4-v1","count":"3"}]`, state version 3 → 4 |
| Эдж `ad0ab577…` («tedj») | `vdcUid`, `vdcType`, `qosProfile="QoS-100Mbit"`, `vdcGroupUid=""`, `needEnableAVI=true`, `virtualServicesCount="1"`, `routedNetConfiguration` — **ключа `ipSpaceName` НЕТ** | `ipSpaceName="internet-ipv4-v1"`, `virtualServicesCount="4"`, version 1 → 2 |
Ключевое: у орги `vIPConfigure` **присутствует и равен `[{}]`** (пустой элемент);
у эджа `ipSpaceName` **отсутствует** до первого modify.
## 4. Провал операции приходит внутри тела, а не HTTP-кодом
`HAR/org_already exists.har`: создание орги с `organizationType=iaas` и `orgSuffix=suff`:
- `POST /instances` → 201, `POST /instanceOperations` → 201, `validate-cfs` → 204, `run` → 201;
- финальный `GET /instanceOperations/{opUid}`: `submitResult="201"`, `isSuccessful=false`,
`errorLog="Организация с именем 'WZ03709-iaas' уже существует в рамках ресурсной платформы sandbox.nubes.ru"`.
Вывод: **ошибку операции нужно читать из `errorLog`/`isSuccessful`** поллинга; HTTP-код ничего не скажет.
Дополнительно: имя орги формируется как `<suffix>-<тип>` (`WZ03709-iaas` / `WZ03709-saas`),
то есть в одном realm — по одной орге каждого типа; повтор даёт ту же ошибку.
## 5. Выводы для нашего провайдера
1. `nubes_vc_org.v_ip_configure` — **Required** в схеме (generator мержит create+modify, `loader.go:96`),
но при `Create` не отправляется, а read-back после create вернёт `[{}]` вместо планового значения
→ риск `Provider produced inconsistent result after apply` на создании орги. **Прогоном не проверено.**
2. `nubes_vc_nsxt.ip_space_name` — Optional+Computed: при create ключа в state нет, значение сохраняется
в state, но **SNAT не включается**; включается только следующим `apply` (Update → 372). **Прогоном не проверено.**
3. `RefreshResourceState` (`resources_core/state_refresh.go`) перезаписывает поля из `state.params`;
для modify-only параметров это поведение опасное — в create его включать не следует (универсальная правка генератора).
4. Из п.1–2 следует, что одной правкой «добавить два ресурса-модификатора» инцидент может не закрыться:
схема `nubes_vc_org` останется с Required-полем.
## 6. Ограничения разбора
- `apply`/`plan` не запускались: пункты 1–2 — вывод из кода + HAR, не подтверждены живым прогоном.
- Проверено на одном стенде (dev), одной орге (`NarodOrg` — во втором HAR имя `WZ03709-iaas` уже занято).
- `qosProfile` в create ЛК отправляет пустым, после modify в state = `QoS-100Mbit`.
@@ -0,0 +1,128 @@
# Q&A с Opus: дизайн ресурсов-модификаторов (2026-09-24)
> Кто: вопросы составлены нами (Copilot), ответы — Opus (внешний агент, по разрешению пользователя).
> Контекст: решено делать два ресурса-модификатора (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`).
> Статус: ответы приняты к сведению, **код не писался**, часть утверждений Opus мною не проверена (пометки ниже).
## Вопросы и ответы
### 1. Инварианты Read/Delete ресурса-модификатора
**Ответ Opus:**
- Read: `RemoveResource` только если родитель исчез (404/deleted) — у нас есть `ShouldRemoveFromState`
(Opus ссылается на `modifier.go`). Расхождение значения параметра ≠ повод удалять ресурс: это дрейф,
обновить поле в state.
- Delete = inverse modify (`count=0` / `needEnableAVI=false` / `ipSpaceName="no-needed"`) — «шаблон
`DeleteStrategy=inverse` + `override` уже реализован».
- Если родитель уже удалён: inverse пропустить, ресурс убрать из state (no-op + Warning), не падать на ошибке API.
### 2. Reset-to-default в `*WithDefaults`
**Ответ Opus:** защита «уже встроена»: и `RunInstanceOperationUniversalWithDefaults` (`operation_run.go:138`),
и by-code путь (`operation_run_bycode.go:108`) досылают незаданные параметры с приоритетом
**live `state.params` → `paramValue` формы → `defaultValue`**. Достаточно шлать только `ipSpaceName`.
Отдельный pre-read live + merge делать не нужно; `ByCode`/`ByIdempotent` — не нужны.
Дополнительно `RunOperationByCodeIdempotent` (`check_before_run`) сверяет desired == current и пропускает лишний run.
### 3. Генератор: modify-only параметр с `required: true`
**Ответ Opus (минимальный набор):**
- **(а)** modify-only → всегда Optional (снять Required в схеме). Обязательно.
- **(б)** исключить modify-only из create-read-back (не добавлять его `InputField` в Create/Read). Обязательно.
- **(в)** «после create догонять modify» — **не нужно**: это ответственность отдельного modifier-ресурса.
- Breaking: снятие Required — не breaking (Optional шире). Breaking — если **удалить** атрибут из схемы
instance у тех, кто его уже прописал в `.tf`. Формулировка Opus: «modify-only параметров в схеме instance
быть не должно вовсе — их место в modifier-ресурсе».
### 4. Диагноз «inconsistent result after apply» на создании орги
**Ответ Opus: подтверждает.** `state_refresh.go`, цикл `inputs`: берёт `paramsMap["vIPConfigure"]` из
`state.params` (платформа отдаёт `[{}]`), через `setFieldValue`/`ParseString` перекрывает план; для
Required-атрибута TF требует final == config → ошибка. Корректно: не читать modify-only обратно в Create
(п.3б) и вернуть запланированное значение, либо Optional+Computed со схлопыванием `[{}]`→null.
### 5. Порядок destroy
**Ответ Opus:** явный `depends_on` нужен — связь между org-IP и SNAT идёт по **имени** ipSpace, ребра графа
TF не видит. Цепочка: `vdc → org → org-IP → edge → SNAT → кластер`; при корректных `depends_on` destroy
пойдёт в обратном порядке. Обязательные рёбра: SNAT → org-IP, modifier → родитель. Достаточно при условии,
что inverse-Delete терпит уже удалённого родителя (п.1).
### 6. Трактовка `[{}]` в Read
**Ответ Opus:** `[{}]` = «не выделено», нормализовать в null/пусто. `count=0` и `[{}]` — одно состояние
«пусто», иначе ложный дрейф на каждом plan.
## Мои замечания к ответам (не проверено кодом, требует внимания)
1. **Opus опирается на machinery отменённого захода.** Он говорит про `modifier.go`, `DeleteStrategy=inverse`,
`override`, «уже реализовано». Это шаблон генератора из эпохи `kind: modifier`, которую мы **сознательно
отменили** (см. баннер LEGACY в `NOTES/20_prompts/**`, `docs/60_strategy/modifier_resources_ideology_and_specification.md`).
Ответы про «уже встроено» нельзя принимать как готовое решение — это код отменённой ветки.
2. **Противоречие внутри п.3:** сначала «modify-only → всегда Optional (оставить в схеме instance)»,
потом «modify-only в схеме instance быть не должно вовсе». Это разные изменения: Optional+Computed vs удаление.
Нужно выбрать одно, иначе получим двух владельцев одного параметра (instance-ресурс и модификатор).
3. **Два владельца параметра.** Если `ip_space_name` остаётся в `nubes_vc_nsxt` **и** появляется
`nubes_vc_nsxt_snat`, Terraform не увидит конфликт: оба будут шлать 372. Значит, из `Update`
сгенерированного `nubes_vc_nsxt` параметр надо убирать — иначе fight/drift. В ответах Opus этого нет.
4. **П.2 не проверял сам.** Утверждение «приоритет live → paramValue → defaultValue уже встроен» противоречит
комментарию в `19_vc_org_resource.go` про reset-баг (`state_params["needEnableAVI"]="false"`, когда на
платформе `true`). Нужна проверка `operation_run.go:138` и `operation_run_bycode.go:108` по коду.
5. **Политика destroy для не-нашей орги.** Орга не в state (адресация по uid). При `destroy` конфигурации
родитель не удаляется — но org-IP-модификатор по §1 выполнит inverse (`count=0`). Нужно решение:
снимать квоту или оставлять (`keep_on_destroy`)— у Opus этого нет.
6. **Один элемент vs весь массив.** `vIPConfigure` — массив. Если ресурс управляет одним элементом
(по `ip_space_name`), то два ресурса на разные ipSpace возможны; если всем массивом — нет. `count=0`
как inverse предполагает поэлементную модель, но в ответах это не зафиксировано.
---
# Раунд 2 (те же сутки): ответы Opus на 5 уточняющих вопросов
### Про противоречие в п.3 (раунд 1)
Opus признал: это были две несовместимые опции.
- **Канон (цель):** modify-only параметра в схеме instance быть не должно — владелец отдельный modifier-ресурс.
- **«Всегда Optional»** — только переходный вариант, если параметр временно оставлен в instance.
- Одновременно оба тезиса не действуют.
### 1. Два владельца 372/662
Один владелец. Instance **перестаёт слать** 372/662: убрать из `ModifyParams` генератора
(не эмитить в `params` map в `instance.go:478`, Update). Незаданные параметры при этом не сбросятся —
досылаются из live `state.params` (см. п.2 раунда 1). Поле в instance остаётся максимум как read-back
(Computed) либо убирается вовсе.
### 2. Массив vs элемент
`vIPConfigure` — `array-map-fixed` с **replace-семантикой всего массива**: отправка `[{name,count}]`
перезаписывает массив целиком.
- **Один ресурс = весь массив** — просто и безопасно.
- Два ресурса на разные ipSpace — только с read→merge→send-full-array; без merge last-write-wins.
- **Рекомендация MVP Opus: один ресурс = весь массив.** Мультиресурс по имени — отдельная фича.
### 3. Destroy, когда родитель не наш
Флаг `keep_on_destroy` (bool, Optional):
- родитель жив и `keep_on_destroy=false` (дефолт) → inverse (`count=0`);
- родитель 404 / вне нашего контроля → пропустить + Warning (не падать);
- `keep_on_destroy=true` → всегда no-op + Warning.
### 4. Вывод атрибута из instance-схемы (не breaking)
Два шага:
- **сейчас**: `Deprecated: "..."` + **Optional+Computed** + прекратить отправку в modify (read-back остаётся);
- **следующий major**: удалить атрибут.
### 5. Тип атрибута в новом ресурсе
**String + JSON + `JsonNormalize()`** (как сейчас `v_ip_configure`), потому что:
- wire-формат `array-map-fixed` — JSON-строка;
- `json_planmodifier.go` — `planmodifier.String` (на list/nested не встанет);
- `RefreshResourceState` читает input-поля только как scalar string/bool/int.
Nested list даёт лучший UX, но требует нового кода в `state_refresh.go`. Для MVP — String+JsonNormalize.
## Мои замечания к раунду 2
1. **П.2 меняет интерфейс заявленного ресурса.** Мы планировали `nubes_vc_org_ip_allocation`
с `ip_space_name` + `count` (по элементу). Opus рекомендует «один ресурс = весь массив»
(list `{name,count}`). Это разные ресурсы по UX и по семантике Delete — требует решения пользователя.
2. **Проверяемость.** Утверждение про `instance.go:478` и про приоритет live при дозаполнении я не
проверял по коду — числовой якорь может быть неточным (в прошлом ответе он ссылался на
`modifier.go` отменённой ветки).
3. **`Deprecated` + `Optional+Computed`** — единственный вариант, который проходит без breaking, согласен;
но это правка сгенерированной схемы → правится в генераторе, не в `resources_gen/*.go`.
## Что дальше
- Решение пользователя по п.2 замечаний (элемент vs весь массив).
- Проверка по коду приоритета live-дозаполнения и строки `instance.go:478` (чтение, без правок).
- После решения — план реализации 2 ресурсов.
@@ -51,8 +51,13 @@ k8sShturval -> create
## 3. Подтверждённые факты (с источниками)
1. **Схема tf-ресурса строится ТОЛЬКО из `create`** (генератор `TOOLS/resource-generator`).
→ modify-only параметры в схему не попадают.
1. ⛔ **ИСПРАВЛЕНО 2026-09-24. Прежняя формулировка «схема строится ТОЛЬКО из `create`» — НЕВЕРНА.**
Генератор **мержит** create+modify: `TOOLS/resource-generator/internal/loader/loader.go:96` →
`schemaParams := params.Merge(createParams, modifyParams)`; коммит `261809b` (2026-09-22)
«is_modifiable=true → параметр НЕ create-only».
Следствие: modify-параметры **уже в схемах** и применяются в `Update` —
`nubes_vc_org.v_ip_configure` (шлёт `662`), `nubes_vc_nsxt.ip_space_name` (шлёт `372`).
Разбор и live-факты: `NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md`.
2. **`vIPConfigure`** (vc_org, modify id **207**, param id **662**, `array-map-fixed`, sub: `name`=39, `count`=40)
есть **только** в modify. В `create` (id 136) — только `resourceRealm`(418), `organizationType`(556), `orgSuffix`(1125).
Файл: `generated/dev/resources_yaml/19_vc_org.yaml`.
@@ -141,6 +146,11 @@ k8sShturval -> create
«Update = no-op». **Файл перезаписан** актуальными фактами. НЕ использовать старую формулировку.
3. **Старые «модификаторы» были написаны и даже работали** (09-22), но заход признан негодным:
доменную логику вшили в универсальный генератор (метки в YAML). Соответствующие документы помечены баннером LEGACY.
4. **Ложный «факт» §3.1 («схема только из `create`»).** Проверено в коде 2026-09-24: генератор мержит
create+modify (`loader.go:96`), поэтому `v_ip_configure` и `ip_space_name` **уже есть** в схемах
`nubes_vc_org` / `nubes_vc_nsxt` и работают через `Update`. Вывод «прописать поле в .tf → падает на plan»
относится максимум к провайдеру, собранному до коммита `261809b` (2026-09-22). Детали —
`NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md`.
---
@@ -151,6 +161,9 @@ k8sShturval -> create
- `NOTES/30_analysis/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md` — анализ, варианты A–E, мнение
- `NOTES/30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md` — ответ Opus + поправки (ложные блокеры сняты)
- `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md` — проверенные факты по vIPConfigure
- `NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md` — разбор create орги/эджа + свежий `state.params` (`vIPConfigure: [{}]`, отсутствие `ipSpaceName`)
- `NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md` — правки/ограничения (часть опровергнута тестом; раньше в карте отсутствовал)
- `HAR/globak.har`, `HAR/org_already exists.har` — записи ЛК от 2026-09-24
- `NOTES/20_prompts/prompt_for_opus_iac_shturval_modify.md` — промпт (факты исправлены)
- `generated/dev/resources_yaml/19_vc_org.yaml`, `22_vc_nsxt.yaml` — спеки (факты по операциям/параметрам)
- `HAR/org_enough_.har`, `HAR/org2.har`, `HAR/edge_.har` — live-семантика modify
+1 -1
View File
@@ -5,7 +5,7 @@
| Стенд | Namespace | Версия | Дата заливки |
|---|---|---|---|
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
| DEV | `nubes-dev` | `2.0.13` | 2026-09-22 | (fix: ShouldRemoveFromState читает deleted/404 через GetInstanceStateRaw — тихое удаление из state без падения на дрейфе) |
| DEV | `nubes-dev` | `2.0.19` | 2026-09-24 | (fix: канонизация `vip_configure` — баг порядка ключей `jsonencode`; запрет `null` в Required-атрибутах; ошибки API в Delete → error) |
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
## Как проверить
-46
View File
@@ -1,46 +0,0 @@
# Что НЕ надо класть в Terraform и почему
## Не управляется Terraform — делается в Личном кабинете (один раз)
### vcOrg / vcVdc / vcNsxt — облачный фундамент
| Сервис | Почему не Terraform |
|--------|---------------------|
| **Организация в Cloud Director** (vcOrg) | Одна на resources realm. Нельзя создать вторую. При destroy — карантин на 14 дней, блокирует всё. |
| **Виртуальный датацентр vDC** (vcVdc) | Квота: 1 на организацию. Создание через Terraform ломается на API (500 на `GET cfsParams`). |
| **Сетевой шлюз Edge** (vcNsxt) | Квота: 1 на vDC. Требует AVI, SNAT, ipSpace — настройка в 3 шага (create → modify org → modify edge). Terraform не умеет create+modify в одном apply. |
**Вывод**: фундамент всегда уже есть у любого клиента. Создаётся один раз через ЛК.
### Штурвал (Kubernetes-кластер)
| Почему не Terraform |
|---------------------|
| Одноразовая операция. Модификаций почти нет — workers, версия, диски, и то редко. |
| Destroy = потеря кластера со всеми подами и данными. `suspend_on_destroy` спасает, но риск велик. |
| Процесс создания длительный, чувствителен к таймаутам и сбоям — в ЛК виднее что происходит. |
## Что Управляется Terraform — PaaS/SaaS слой
```
PostgreSQL Redis Kafka ClickHouse RabbitMQ MariaDB MongoDB
S3 Buckets Node.js Lucee Gitea Nextcloud Harbor ...
```
Эти сервисы создаются/меняются/удаляются многократно, имеют повторяемые конфигурации и связаны зависимостями (app → db, app → s3).
## Схема
```
┌─ Личный кабинет (один раз) ──────────┐
│ vcOrg → vcVdc → vcNsxt (Edge+AVI) │
│ Штурвал (Kubernetes-кластер) │
└──────────────────────────────────────┘
│ edge_uid
▼
┌─ Terraform (IaC, повторяемо) ────────┐
│ Postgres, S3, Redis, Kafka, │
│ RabbitMQ, ClickHouse, MariaDB, │
│ MongoDB, Node.js, Lucee, Gitea ... │
└──────────────────────────────────────┘
```
+75
View File
@@ -0,0 +1,75 @@
# Ресурсы-модификаторы: внешние IP организации и SNAT на шлюзе
> ⚠️ Живым прогоном пока **не проверено**. Описаны ресурсы и порядок применения; выводы о поведении
> сделаны по коду провайдера и HAR-записям ЛК.
Два ресурса выполняют операции `modify` над **уже существующими** услугами. Организация Cloud Director
и сетевой шлюз периметра создаются один раз **вручную в ЛК** и адресуются по UUID.
## `nubes_vc_org_ip_allocation`
Выделяет внешние IP на организации (операция `modify`, параметр `vIPConfigure`).
| Атрибут | Тип | Описание |
|---|---|---|
| `org_uid` | string, обязательный | UUID услуги «Организация в Cloud Director» |
| `vip_configure` | string (JSON), обязательный | Массив аллокаций: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строка |
| `keep_on_destroy` | bool, по умолчанию `false` | Не снимать квоту при `destroy` |
Порядок ключей и форматирование не важны — значение канонизируется при планировании
(важно потому, что `jsonencode` сортирует ключи по алфавиту).
Снять аллокацию через `vip_configure` **нельзя** (пустой массив отклоняется): для этого удали ресурс —
тогда отправится обратный `modify` с `count = "0"`.
```hcl
resource "nubes_vc_org_ip_allocation" "this" {
org_uid = var.org_uid
vip_configure = jsonencode([
{ name = "internet-ipv4-v1", count = "3" }
])
}
```
Операция имеет **replace-семантику**: массив перезаписывается целиком, поэтому ресурс владеет всей
аллокацией организации, а не отдельным элементом.
При `destroy` по умолчанию отправляется обратный `modify` с `count = "0"` — квота обнуляется.
## `nubes_vc_nsxt_snat`
Включает или выключает SNAT на сетевом шлюзе периметра (операция `modify`, параметр `ipSpaceName`).
| Атрибут | Тип | Описание |
|---|---|---|
| `nsxt_uid` | string, обязательный | UUID услуги «Сетевой шлюз периметра (Edge)» |
| `ip_space_name` | string, обязательный | Имя ipSpace для внешнего IP; `no-needed` выключает SNAT |
| `keep_on_destroy` | bool, по умолчанию `false` | Не выключать SNAT при `destroy` |
```hcl
resource "nubes_vc_nsxt_snat" "this" {
nsxt_uid = var.nsxt_uid
ip_space_name = "internet-ipv4-v1"
depends_on = [nubes_vc_org_ip_allocation.this]
}
```
Имя ipSpace должно быть предварительно выделено на организации, поэтому SNAT-ресурс **должен зависеть**
от `nubes_vc_org_ip_allocation`. При `destroy` отправляется `ipSpaceName = "no-needed"`.
## Зачем отдельные ресурсы
В схемах ресурсов-инстансов (`nubes_vc_org`, `nubes_vc_nsxt`) эти параметры присутствуют только в
операции `modify` — `create` их не отправляет. Отдельные ресурсы позволяют собрать цепочку
**в одном `apply`** и выстроить корректный порядок зависимостей.
## Импорт
Оба ресурса импортируются по UUID родительской услуги:
```bash
terraform import nubes_vc_org_ip_allocation.this <org_uid>
terraform import nubes_vc_nsxt_snat.this <nsxt_uid>
```
+1
View File
@@ -60,3 +60,4 @@ nav:
- Глоссарий: 30_registry/guides/glossary.md
- Проверенные примеры:
- PostgreSQL: curated/postgres/pg_user_db.md
- Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md
+4
View File
@@ -182,6 +182,10 @@ func (p *NubesProvider) Configure(ctx context.Context, req provider.ConfigureReq
func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource {
resources := resources_gen.AllResources()
resources = append(resources, resources_core.NewServiceOperationResource)
// Ресурсы-модификаторы для операций, которых нет в create-схеме ресурсов-инстансов.
// Организация и шлюз создаются вручную в ЛК, поэтому адресуются по uid, а не ссылкой на ресурс.
resources = append(resources, resources_core.NewOrgIpAllocationResource)
resources = append(resources, resources_core.NewNsxtSnatResource)
return resources
}
@@ -0,0 +1,258 @@
package resources_core
import (
"context"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &NsxtSnatResource{}
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
//
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
//
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
type NsxtSnatResource struct {
client *core.UniversalClient
}
type NsxtSnatModel struct {
ID types.String `tfsdk:"id"`
NsxtUID types.String `tfsdk:"nsxt_uid"`
IpSpaceName types.String `tfsdk:"ip_space_name"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// noNeededIpSpace — каноническое значение «SNAT не нужен».
const noNeededIpSpace = "no-needed"
func NewNsxtSnatResource() resource.Resource {
return &NsxtSnatResource{}
}
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
}
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
"Значение `no-needed` выключает SNAT.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"nsxt_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ip_space_name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
"`ipSpaceName = \"no-needed\"`).",
},
},
}
}
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan NsxtSnatModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если ключа ещё нет
// (SNAT ни разу не включали, HAR fresh-create) — оставляем текущее значение state.
if raw, ok := live["ipSpaceName"]; ok && strings.TrimSpace(raw) != "" {
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
}
state.ID = types.StringValue(nsxtUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state NsxtSnatModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
if nsxtUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
if err != nil {
// Реальная ошибка API (не «шлюза нет») — нельзя молча терять SNAT: ресурс уйдёт из state,
// а SNAT останется включённым.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.Diagnostics.AddWarning(
"SNAT не выключался",
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
)
return
}
unlock := r.client.LockInstance(nsxtUID)
defer unlock()
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
"ipSpaceName": noNeededIpSpace,
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"SNAT выключен",
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
)
}
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
}
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
uid := strings.TrimSpace(nsxtUID.ValueString())
if uid == "" {
return fmt.Errorf("nsxt_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
// Пустую строку молча подменять нельзя (скрытое поведение + риск вечного diff).
// Выключение SNAT — явное каноническое значение "no-needed".
value := strings.TrimSpace(ipSpaceName.ValueString())
if value == "" {
return fmt.Errorf("ip_space_name не может быть пустым: укажите имя ipSpace или %q для выключения SNAT", noNeededIpSpace)
}
unlock := r.client.LockInstance(uid)
defer unlock()
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
// а не с live-состоянием инстанса — можно ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"ipSpaceName": value,
})
}
@@ -0,0 +1,384 @@
package resources_core
import (
"context"
"encoding/json"
"fmt"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &OrgIpAllocationResource{}
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
//
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
// и адресуется здесь по uid.
//
// Семантика операции — replace всего массива: переданное значение полностью заменяет
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
type OrgIpAllocationResource struct {
client *core.UniversalClient
}
type OrgIpAllocationModel struct {
ID types.String `tfsdk:"id"`
OrgUID types.String `tfsdk:"org_uid"`
VIPConfigure types.String `tfsdk:"vip_configure"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
}
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
type vipAllocation struct {
Name string
Count string
}
func NewOrgIpAllocationResource() resource.Resource {
return &OrgIpAllocationResource{}
}
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
}
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"org_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"vip_configure": schema.StringAttribute{
Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
"Порядок ключей и форматирование не важны — значение канонизируется при планировании. " +
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
PlanModifiers: []planmodifier.String{
vipConfigureCanonical(),
},
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
"`count=0` по каждому элементу).",
},
},
}
}
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan OrgIpAllocationModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
// Организации больше нет — ресурс тоже не нужен.
resp.State.RemoveResource(ctx)
return
}
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если платформа
// ещё не вернула значение (у свежей орги `vIPConfigure: [{}]`), оставляем текущее значение state.
raw, ok := live["vIPConfigure"]
if ok {
items, parseErr := parseVipConfigure(raw)
if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return
}
if len(items) > 0 {
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
}
}
state.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state OrgIpAllocationModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
return
}
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
)
return
}
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
if err != nil {
// Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state,
// а выделенные IP останутся висеть.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if remove {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
)
return
}
unlock := r.client.LockInstance(orgUID)
defer unlock()
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
items := []vipAllocation{}
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
items = parsed
}
}
if len(items) == 0 {
resp.Diagnostics.AddWarning(
"Аллокация IP не снималась",
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
)
return
}
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
zero := make([]vipAllocation, 0, len(items))
for _, item := range items {
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
}
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(zero),
}); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
resp.Diagnostics.AddWarning(
"Квота IP обнулена",
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
)
}
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*core.UniversalClient)
if !ok {
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
return
}
r.client = client
}
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("org_uid"), uid)...)
}
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID.ValueString())
if uid == "" {
return fmt.Errorf("org_uid обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
}
items, err := parseVipConfigure(vipConfigure.ValueString())
if err != nil {
return err
}
if len(items) == 0 {
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
}
unlock := r.client.LockInstance(uid)
defer unlock()
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
"vIPConfigure": formatVipConfigure(items),
})
}
// parseVipConfigure разбирает значение параметра vIPConfigure.
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
func parseVipConfigure(raw string) ([]vipAllocation, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
}
out := make([]vipAllocation, 0, len(items))
for _, item := range items {
name := ""
if v, ok := item["name"]; ok && v != nil {
name = strings.TrimSpace(fmt.Sprint(v))
}
if name == "" {
continue
}
count := "0"
if v, ok := item["count"]; ok && v != nil {
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
count = parsed
}
}
out = append(out, vipAllocation{Name: name, Count: count})
}
return out, nil
}
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read,
// иначе план и state расходятся по строке — см. vipConfigureCanonical.
func formatVipConfigure(items []vipAllocation) string {
if len(items) == 0 {
return "[]"
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count))
}
return "[" + strings.Join(parts, ",") + "]"
}
// canonicalVipConfigure приводит пользовательский ввод к каноническому виду.
// Нужен потому, что Terraform `jsonencode` сортирует ключи по алфавиту (`count` раньше `name`),
// а API/HAR дают порядок `name,count`: без канонизации план и Read расходятся → вечный diff.
// Невалидный JSON возвращаем как есть — содержательную ошибку выдаст apply.
func canonicalVipConfigure(raw string) string {
items, err := parseVipConfigure(raw)
if err != nil {
return raw
}
return formatVipConfigure(items)
}
// vipConfigureCanonical — plan modifier для атрибута vip_configure.
type vipConfigureCanonicalPlanModifier struct{}
func vipConfigureCanonical() planmodifier.String {
return vipConfigureCanonicalPlanModifier{}
}
func (m vipConfigureCanonicalPlanModifier) Description(_ context.Context) string {
return "Приводит JSON-массив vIPConfigure к каноническому виду (чтобы план совпадал с результатом Read)."
}
func (m vipConfigureCanonicalPlanModifier) MarkdownDescription(ctx context.Context) string {
return m.Description(ctx)
}
func (m vipConfigureCanonicalPlanModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
if req.PlanValue.IsNull() || req.PlanValue.IsUnknown() {
return
}
resp.PlanValue = types.StringValue(canonicalVipConfigure(req.PlanValue.ValueString()))
}
@@ -0,0 +1,98 @@
package resources_core
import "testing"
// Проверяем нормализацию vIPConfigure: свежая орга отдаёт `[{}]` (пустой элемент) —
// это «не выделено», а не аллокация (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md).
func TestParseVipConfigure_EmptyAndBroken(t *testing.T) {
cases := []struct {
name string
raw string
want int
}{
{"пустая строка", "", 0},
{"пустой массив", "[]", 0},
{"пустой элемент (свежая орга)", "[{}]", 0},
{"только name без count", `[{"name":"internet-ipv4-v1"}]`, 1},
{"элемент без name", `[{"count":"3"}]`, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseVipConfigure(tc.raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != tc.want {
t.Fatalf("получено %d элементов, ожидалось %d (%+v)", len(got), tc.want, got)
}
})
}
}
func TestParseVipConfigure_CountAsString(t *testing.T) {
// count у платформы может прийти и числом (в старых стейтах) — нормализуем в строку.
got, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":4}]`)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if len(got) != 1 || got[0].Count != "4" {
t.Fatalf("ожидался count=\"4\", получено %+v", got)
}
}
func TestFormatVipConfigure_Canonical(t *testing.T) {
got := formatVipConfigure([]vipAllocation{{Name: "internet-ipv4-v1", Count: "3"}})
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
if got != want {
t.Fatalf("получено %q, ожидалось %q", got, want)
}
if empty := formatVipConfigure(nil); empty != "[]" {
t.Fatalf("для пустого списка ожидалось \"[]\", получено %q", empty)
}
}
// Канонизация пользовательского ввода: terraform jsonencode сортирует ключи по алфавиту
// (count раньше name), а канон у нас — name,count. Без канонизации план ≠ state → вечный diff
// (баг воспроизведён через `terraform console`, см. NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md).
func TestCanonicalVipConfigure_NormalizesJsonencodeForm(t *testing.T) {
raw := `[{"count":"3","name":"internet-ipv4-v1"}]` // так отдаёт jsonencode
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
if got := canonicalVipConfigure(raw); got != want {
t.Fatalf("получено %q, ожидалось %q", got, want)
}
}
func TestCanonicalVipConfigure_CompactsAndDropsEmptyElements(t *testing.T) {
raw := `[ { "count" : "4" , "name" : "internet-ipv4-v1" }, {} ]`
want := `[{"name":"internet-ipv4-v1","count":"4"}]`
if got := canonicalVipConfigure(raw); got != want {
t.Fatalf("получено %q, ожидалось %q", got, want)
}
if got := canonicalVipConfigure(`[{}]`); got != "[]" {
t.Fatalf("для [{}] ожидалось \"[]\", получено %q", got)
}
}
func TestCanonicalVipConfigure_InvalidJSONLeftAsIs(t *testing.T) {
raw := `{not json`
if got := canonicalVipConfigure(raw); got != raw {
t.Fatalf("невалидный JSON должен остаться как есть: %q → %q", raw, got)
}
}
func TestParseVipConfigure_RoundTripIsStable(t *testing.T) {
raw := `[{"name":"internet-ipv4-v1","count":"4"}]`
items, err := parseVipConfigure(raw)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if again := formatVipConfigure(items); again != raw {
t.Fatalf("round-trip не стабилен: %q → %q", raw, again)
}
}
func TestParseVipConfigure_InvalidJSON(t *testing.T) {
if _, err := parseVipConfigure(`{"name":"x"}`); err == nil {
t.Fatal("ожидалась ошибка на объект вместо массива")
}
}