Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721c3fcfab | ||
|
|
e6675be906 | ||
|
|
ed4493c0ee | ||
|
|
1236c59e18 | ||
|
|
4b497e61db | ||
|
|
ba6c4f5122 | ||
|
|
3374bf4e08 | ||
|
|
648db99628 | ||
|
|
9412106e3f | ||
|
|
6e6d223c22 | ||
|
|
62abcd64f5 | ||
|
|
3973f912fc | ||
|
|
73a7459a38 | ||
|
|
80d82a145a | ||
|
|
22cf2595ee | ||
|
|
574e300476 | ||
|
|
cb8389c17f | ||
|
|
664f04eb49 | ||
|
|
602b27ee1a | ||
|
|
97d5ca818e | ||
|
|
bccf8f7320 | ||
|
|
129dab97a0 | ||
|
|
75700a92da | ||
|
|
51ff9b3751 |
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 | (новая нумерация) |
|
||||
|
||||
## Как проверить
|
||||
|
||||
@@ -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 ... │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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("ожидалась ошибка на объект вместо массива")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user