10 Commits
Author SHA1 Message Date
Repinoid 418b5645e5 docs: DEV 2.0.21 залит — ресурс аллокации принимает имя организации; стенд переведён на 2.0.21 2026-09-24 15:35:18 +03:00
Repinoid 5bd197f031 feat(provider): ресурс аллокации принимает имя организации (резолв в UUID через ResolveRefSvcParamValue, как в nubes_vc_vdc); конфиги и доки без org_uid 2026-09-24 15:28:13 +03:00
Repinoid bd5de0cead docs(pipeline): страница переписана как инструкция для пользователя (шаги, значения из ЛК, команды) 2026-09-24 15:21:41 +03:00
Repinoid c3b82cf074 docs(pipeline): ссылка на репозиторий примеров tf_examples и порядок клонирования 2026-09-24 15:00:07 +03:00
Repinoid 9590005914 docs: страница пайплайна vDC → Edge → внешние IP → SNAT (Ресурсы-модификаторы (IP организации, SNAT)) 2026-09-24 14:56:00 +03:00
Repinoid caa55d9ff8 chore(stand/FullPipe): провайдер 2.0.20 (проверено plan из реестра) 2026-09-24 14:16:05 +03:00
Repinoid d76418303a docs: DEV 2.0.20 залит (nubes-dev) — исправлен plan-modifier в nubes_vc_org_ip_allocation 2026-09-24 14:11:33 +03:00
Repinoid 6196a0119a docs(rules): добавить правило — при неясной команде переспросить и подтвердить, не гадать 2026-09-24 14:05:06 +03:00
Repinoid e25ef02a1a docs: убрать устаревшее «канонизация в plan-modifier» (совет Opus был неверен); план живого прогона FullPipe 2026-09-24 14:04:08 +03:00
Repinoid 807dfde287 fix(provider): убран plan-modifier, менявший пользовательское значение (Terraform: planned value must match config); сравнение аллокаций — смысловое в Read 2026-09-24 13:57:56 +03:00
12 changed files with 291 additions and 104 deletions
+2 -1
View File
@@ -13,4 +13,5 @@
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений. коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ. ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ. НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ. ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
+2 -2
View File
@@ -16,7 +16,7 @@
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком) # 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" { resource "nubes_vc_org_ip_allocation" "org_ip" {
org_uid = var.org_uid organization = var.organization
vip_configure = jsonencode([ vip_configure = jsonencode([
{ {
@@ -45,7 +45,7 @@ resource "nubes_vc_nsxt_snat" "snat" {
output "allocated_org_ip" { output "allocated_org_ip" {
description = "Выделено внешних IP на организации" description = "Выделено внешних IP на организации"
value = { value = {
org_uid = var.org_uid organization = var.organization
ip_space_name = var.ip_space_name ip_space_name = var.ip_space_name
ip_count = var.ip_count ip_count = var.ip_count
} }
-6
View File
@@ -16,12 +16,6 @@ variable "organization" {
description = "Имя или UUID организации (vc_org)" description = "Имя или UUID организации (vc_org)"
} }
# UUID той же организации — нужен ресурсам-модификаторам (они адресуются строго по uid)
variable "org_uid" {
type = string
description = "UUID организации (vc_org) для nubes_vc_org_ip_allocation"
}
# --- Модификаторы (IP на орге + SNAT на эдже) --- # --- Модификаторы (IP на орге + SNAT на эдже) ---
variable "ip_space_name" { variable "ip_space_name" {
+1 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers { required_providers {
nubes = { nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes" source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "2.0.19" version = "2.0.21"
} }
} }
} }
@@ -0,0 +1,78 @@
# ПЛАН: живой прогон цепочки на DEV_STAND/FullPipe (2026-09-24)
> Стенд: dev, орга **`organ`** (`57eeacd1-dc7f-4a52-b903-7e5f7d3c1164`, realm `sandbox.nubes.ru`, тип `saas`,
> CD-имя `WZ01325-saas`). Провайдер `2.0.19` (`terraform init -upgrade` уже сделан, `validate` — Success).
> **`apply`/`destroy` запускает только пользователь.**
## 0. Что уже готово
- Ресурсы `nubes_vc_org_ip_allocation` (modify `vIPConfigure`) и `nubes_vc_nsxt_snat` (modify `ipSpaceName`) —
в провайдере, собраны в `2.0.19`, залиты в `nubes-dev`, есть unit-тесты канонизации.
- Конфиг стенда: `DEV_STAND/FullPipe/` — `vdc.tf`, `edge.tf`, `modifiers.tf` (аллокация после эджа, затем SNAT),
`organization = "organ"` + `org_uid`.
- Орга создана вручную (в tf её нет) — по решению пользователя.
## 1. Цель прогона
Проверить **одним `apply`**: `vdc → edge → IP на орге → SNAT`, затем чистый повторный `plan` и корректный
`destroy`. Это первый живой прогон обоих новых ресурсов: CRUD до сих пор не проверялся.
## 2. Перед прогоном (проверить значения)
1. `vdc_network_provider` (`snb1`), `vdc_provider_vdc` (`Intel Broadwell 2.4`), `vdc_storage_config` (`SATA`) —
убедиться в ЛК, что доступны для орги `organ` (значения брались из ЛК для прежней орги).
2. `ip_space_name` — сначала может быть недоступен: **список ipSpace в ЛК падает** (`Can't cast Complex Object
Type Struct to String`), пока нет vDC/эджа. Брать имя из прежних HAR: `internet-ipv4-v1`.
3. `ip_count` — `"3"` (строка).
## 3. Шаги прогона (пользователь)
| # | Команда | Ожидаемый результат |
|---|---|---|
| 1 | `terraform plan` | создание: `nubes_vc_vdc.vdc` → `nubes_vc_nsxt.edge` → `nubes_vc_org_ip_allocation.org_ip` → `nubes_vc_nsxt_snat.snat`; порядка не меньше |
| 2 | `terraform apply` | всё создаётся за один проход |
| 3 | `terraform plan` (повторно) | **пустой** — главный тест канонизации (иначе вечный diff) |
| 4 | проверить API (см. §4) | `vIPConfigure` и `ipSpaceName` в live-состоянии |
| 5 | изменить `ip_count` 3 → 2, `plan`+`apply` | меняется только аллокация, state сходится |
| 6 | `terraform destroy` | порядок `snat (no-needed)` → `org_ip (count=0)` → `edge` → `vdc`; орги не касается |
## 4. Что проверять и чем
```bash
TOK=$(tr -d '\n' < secrets/narodDEV.token) # токен орги organ
# состояние орги
curl -s -H "Authorization: Bearer $TOK" 'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/<org_uid>'
# состояние эджа
curl -s -H "Authorization: Bearer $TOK" 'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/<nsxt_uid>'
```
**Гипотезы, которые прогон подтверждает/опровергает:**
1. **Имена live-ключей**: `state.params.vIPConfigure` (орга) и `state.params.ipSpaceName` (эдж) — взяты из HAR,
кодом не проверены. Если Read вернёт не то → увидим дрейф/пустое значение.
2. **Частичный payload не затирает остальное**: SNAT-модификация шлёт только `372`; `needEnableAVI`
и `virtualServicesCount` должны остаться прежними (`true` / `1`), т.к. досылаются из live
(`core/operation_run_bycode.go`). Проверить в состоянии эджа до/после.
3. **Один `apply`** проходит целиком без второго прогона (ради этого и делались ресурсы).
4. **Нет вечного diff** после apply (канонизация `vip_configure`).
5. **`Required` + пустое live** не даёт ошибок (лечение из ревью).
## 5. Точки отказа и что делать
| Симптом | Вероятная причина | Действие |
|---|---|---|
| аллокация падает `Can't cast ... Struct to String` | платформа ещё не видит `job.vcd.networkProvider`/`providerGateway` (эдж/VDC не в состоянии) | проверить порядок и фактическое состояние эджа; при необходимости — пауза/повторный `apply` |
| `Provider produced inconsistent result after apply` на `vdc`/`edge` | read-back перекрыл план (известный класс дефектов) | записать в NOTES, разбирать отдельно (это уже не про наши ресурсы) |
| повторный `plan` не пустой | порядок ключей/формат не сошлись | сверить, что вернул live, с `formatVipConfigure` |
| SNAT не включился | `372` не доехал / неверное имя ipSpace | проверить `state.params.ipSpaceName` эджа и лог операции |
| `destroy` падает | обратный modify на живой/мёртвый родитель | смотреть тексты диагностик ресурсов (мы развели: ошибка API ≠ «родителя нет») |
## 6. После прогона
1. Отчёт в `NOTES/30_analysis/` — что прошло, что упало, с HAR/логами.
2. Обновить память репозитория (подтверждённые факты вместо гипотез).
3. Если найдутся баги — отдельные коммиты + при необходимости новый релиз провайдера.
4. Публикация документации (`04_build_and_publish_docs.sh`) — отдельной командой.
**Не входит в этот прогон:** кластер Штурвал (`nubes_k8s_shturval_cluster`) — отдельным шагом, после того как
SNAT подтверждён.
@@ -808,14 +808,24 @@ func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planm
(только destroy) — **задокументировать** в описании атрибута. (только destroy) — **задокументировать** в описании атрибута.
- Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым. - Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым.
## Требуется сделать (по итогам ревью) — ВЫПОЛНЕНО (коммиты `ba6c4f5`, `4b497e6`, `1236c59`) ## Итог по ревью: что сделано и где ревью ошиблось
1. ✅ Заменить `JsonNormalize()` на канонизирующий plan-modifier (`parse → formatVipConfigure`) — **⚠️ Совет Opus (вариант «б», канонизация в plan-modifier) — НЕВЕРЕН.** Plan-modifier не имеет права
закрывает баг порядка ключей (в т.ч. для `jsonencode`). менять значение пользовательского атрибута: Terraform отвечает
2. ✅ Убрать запись `null` в `Required`-атрибуты (`vip_configure`, `ip_space_name`) — при пустом live `Provider produced invalid plan: planned value does not match config value`.
сохраняется текущее значение state (проверка «что отправили — то и в state»). Это правило описано в нашем же сгенерированном коде (`22_vc_nsxt_resource.go`, комментарий в `ModifyPlan`).
3. ✅ `Delete`: ошибки API → `AddError`; warning оставлен только для отсутствующего родителя. Проверено живым `terraform plan` 2026-09-24 (ошибка воспроизведена).
4. ✅ `setSnat`: вместо тихой подмены — валидация пустой строки.
Правильное решение (коммит `807dfde`):
- plan-modifier удалён полностью (`JsonNormalize` тоже снят — он компактит, то есть тоже менял бы значение);
- в `Read` — смысловое сравнение `vipAllocationsEqual`: если смысл совпал (порядок ключей/формат не важны),
значение пользователя НЕ переписывается; пишется только реальный дрейф.
**Выполнено корректно:**
1. ✅ Убран plan-modifier, менявший пользовательское значение; сравнение — смысловое (коммит `807dfde`).
2. ✅ `null` в `Required`-атрибуты не пишется — при пустом live сохраняется текущее значение state.
3. ✅ `Delete`: ошибки API → `AddError`; warning только для отсутствующего родителя.
4. ✅ `setSnat`: валидация пустой строки вместо тихой подмены на `no-needed`.
5. ✅ Задокументировано: «снять всё» через `vip_configure` нельзя, только `destroy`. 5. ✅ Задокументировано: «снять всё» через `vip_configure` нельзя, только `destroy`.
6. ✅ Поправлены/добавлены тесты канонизации (`jsonencode`-форма, пробелы, `[{}]`, невалидный JSON). 6. ✅ Тесты: смысловое сравнение (порядок ключей, разный count/имя, пустая аллокация).
7. ⏳ Новый релиз провайдера (2.0.19) с повторной заливкой в `nubes-dev`. 7. ⚠️ Релиз `2.0.19` залит, но **содержит сломанный plan-modifier** — для работы из реестра нужен `2.0.20`.
+1 -1
View File
@@ -5,7 +5,7 @@
| Стенд | Namespace | Версия | Дата заливки | | Стенд | Namespace | Версия | Дата заливки |
|---|---|---|---| |---|---|---|---|
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) | | PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
| DEV | `nubes-dev` | `2.0.19` | 2026-09-24 | (fix: канонизация `vip_configure` — баг порядка ключей `jsonencode`; запрет `null` в Required-атрибутах; ошибки API в Delete → error) | | DEV | `nubes-dev` | `2.0.21` | 2026-09-24 | (feat: ресурс аллокации принимает ИМЯ организации с резолвом в UUID — как `nubes_vc_vdc`; `org_uid` убран) |
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) | | TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
## Как проверить ## Как проверить
+5 -5
View File
@@ -12,19 +12,19 @@
| Атрибут | Тип | Описание | | Атрибут | Тип | Описание |
|---|---|---| |---|---|---|
| `org_uid` | string, обязательный | UUID услуги «Организация в Cloud Director» | | `organization` | string, обязательный | Организация: имя из ЛК или её UUID |
| `vip_configure` | string (JSON), обязательный | Массив аллокаций: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строка | | `vip_configure` | string (JSON), обязательный | Массив аллокаций: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строка |
| `keep_on_destroy` | bool, по умолчанию `false` | Не снимать квоту при `destroy` | | `keep_on_destroy` | bool, по умолчанию `false` | Не снимать квоту при `destroy` |
Порядок ключей и форматирование не важны — значение канонизируется при планировании Порядок ключей и форматирование не важны — сравнение смысловое (важно потому, что `jsonencode` сортирует
(важно потому, что `jsonencode` сортирует ключи по алфавиту). ключи по алфавиту).
Снять аллокацию через `vip_configure` **нельзя** (пустой массив отклоняется): для этого удали ресурс — Снять аллокацию через `vip_configure` **нельзя** (пустой массив отклоняется): для этого удали ресурс —
тогда отправится обратный `modify` с `count = "0"`. тогда отправится обратный `modify` с `count = "0"`.
```hcl ```hcl
resource "nubes_vc_org_ip_allocation" "this" { resource "nubes_vc_org_ip_allocation" "this" {
org_uid = var.org_uid organization = var.organization
vip_configure = jsonencode([ vip_configure = jsonencode([
{ name = "internet-ipv4-v1", count = "3" } { name = "internet-ipv4-v1", count = "3" }
@@ -70,6 +70,6 @@ resource "nubes_vc_nsxt_snat" "this" {
Оба ресурса импортируются по UUID родительской услуги: Оба ресурса импортируются по UUID родительской услуги:
```bash ```bash
terraform import nubes_vc_org_ip_allocation.this <org_uid> terraform import nubes_vc_org_ip_allocation.this organ
terraform import nubes_vc_nsxt_snat.this <nsxt_uid> terraform import nubes_vc_nsxt_snat.this <nsxt_uid>
``` ```
+70
View File
@@ -0,0 +1,70 @@
# Как развернуть vDC, Edge, внешние IP и SNAT
Пошаговая инструкция. Готовые файлы — в репозитории примеров.
Организацию создайте заранее в ЛК: Terraform её не создаёт.
Кластер Штурвал в эту инструкцию не входит — он разворачивается долго, отдельным шагом.
## 1. Скопируйте пример
```bash
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
cd tf_examples/fullpipe_chain
```
## 2. Возьмите значения в ЛК
| Что | Где взять | Пример |
|---|---|---|
| Токен API | ЛК → Профиль → Токены → «Технический» | `eyJhbGciOi...` |
| Имя организации | ЛК → услуга «Организация в Cloud Director» → название услуги | `organ` |
| Сетевой провайдер | ЛК → vDC → «Сетевой провайдер» | `snb1` |
| Provider VDC | ЛК → vDC → «Provider VDC» | `Intel Broadwell 2.4` |
| Дисковая политика | ЛК → vDC → доступные дисковые политики | `SATA` |
| Имя ipSpace | ЛК → организация → внешние IP | `internet-ipv4-v1` |
## 3. Заполните значения
```bash
cp terraform.tfvars.example terraform.tfvars
```
```hcl
api_token = "eyJhbGciOi..." # токен из шага 2
organization = "organ" # имя организации
ip_space_name = "internet-ipv4-v1"
ip_count = "3"
```
## 4. Выполните команды
```bash
terraform init # один раз — скачает провайдер
terraform plan # покажет, что будет создано
terraform apply # создаст (подтвердить: yes)
```
## 5. Проверьте результат
- в ЛК появились виртуальный датацентр и сетевой шлюз;
- на организации выделены внешние IP;
- на шлюзе включён SNAT;
- повторный `terraform plan` изменений не показывает.
## 6. Удаление
```bash
terraform destroy
```
SNAT выключается, квота IP обнуляется, затем удаляются шлюз и vDC. Организация не удаляется.
## Файлы примера
| Файл | Что делает |
|---|---|
| `main.tf` | провайдер и переменные |
| `vdc.tf` | виртуальный датацентр |
| `edge.tf` | сетевой шлюз периметра (Edge) |
| `modifiers.tf` | внешние IP на организации + SNAT на шлюзе |
| `terraform.tfvars.example` | шаблон значений из ЛК |
+1
View File
@@ -60,4 +60,5 @@ nav:
- Глоссарий: 30_registry/guides/glossary.md - Глоссарий: 30_registry/guides/glossary.md
- Проверенные примеры: - Проверенные примеры:
- PostgreSQL: curated/postgres/pg_user_db.md - PostgreSQL: curated/postgres/pg_user_db.md
- Пайплайн vDC → Edge → IP → SNAT: curated/pipeline/vdc_edge_ip_snat.md
- Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md - Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md
@@ -36,7 +36,7 @@ type OrgIpAllocationResource struct {
type OrgIpAllocationModel struct { type OrgIpAllocationModel struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
OrgUID types.String `tfsdk:"org_uid"` Organization types.String `tfsdk:"organization"`
VIPConfigure types.String `tfsdk:"vip_configure"` VIPConfigure types.String `tfsdk:"vip_configure"`
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"` KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
} }
@@ -59,7 +59,7 @@ func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.Met
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{ resp.Schema = schema.Schema{
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " + MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " + "Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.", "Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{ Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{ "id": schema.StringAttribute{
@@ -68,9 +68,10 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
stringplanmodifier.UseStateForUnknown(), stringplanmodifier.UseStateForUnknown(),
}, },
}, },
"org_uid": schema.StringAttribute{ "organization": schema.StringAttribute{
Required: true, Required: true,
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».", MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
"или её UUID.",
PlanModifiers: []planmodifier.String{ PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(), stringplanmodifier.RequiresReplace(),
}, },
@@ -79,11 +80,8 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
Required: true, Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " + MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " + "Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
"Порядок ключей и форматирование не важны — значение канонизируется при планировании. " + "Порядок ключей и форматирование не важны (сравнение смысловое). " +
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).", "Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
PlanModifiers: []planmodifier.String{
vipConfigureCanonical(),
},
}, },
"keep_on_destroy": schema.BoolAttribute{ "keep_on_destroy": schema.BoolAttribute{
Optional: true, Optional: true,
@@ -103,12 +101,18 @@ func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.Creat
return return
} }
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil { orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error()) resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return return
} }
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString())) if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
} }
@@ -119,12 +123,18 @@ func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.Updat
return return
} }
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil { orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error()) resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return return
} }
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString())) if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
} }
@@ -135,8 +145,13 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
return return
} }
orgUID := strings.TrimSpace(state.OrgUID.ValueString()) if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
if orgUID == "" || r.client == nil { return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return return
} }
@@ -157,18 +172,19 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
return return
} }
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом, // Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если платформа // увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
// ещё не вернула значение (у свежей орги `vIPConfigure: [{}]`), оставляем текущее значение state. // Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
raw, ok := live["vIPConfigure"] raw, ok := live["vIPConfigure"]
if ok { if ok {
items, parseErr := parseVipConfigure(raw) liveItems, parseErr := parseVipConfigure(raw)
if parseErr != nil { if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error()) resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return return
} }
if len(items) > 0 { stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
state.VIPConfigure = types.StringValue(formatVipConfigure(items)) if !vipAllocationsEqual(liveItems, stateItems) {
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
} }
} }
@@ -183,8 +199,13 @@ func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.Delet
return return
} }
orgUID := strings.TrimSpace(state.OrgUID.ValueString()) if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
if orgUID == "" || r.client == nil { return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return return
} }
@@ -269,14 +290,37 @@ func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.Conf
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
uid := strings.TrimSpace(req.ID) 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("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("org_uid"), uid)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
}
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
// Резолв делает клиент — тем же путём, что сгенерированный nubes_vc_vdc
// (core.ResolveRefSvcParamValue, сравн. 21_vc_vdc_resource.go).
func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, organization types.String) (string, error) {
if r.client == nil {
return "", fmt.Errorf("клиент не инициализирован")
}
raw := strings.TrimSpace(organization.ValueString())
if raw == "" {
return "", fmt.Errorf("organization обязателен")
}
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
if err != nil {
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
}
resolved = strings.TrimSpace(resolved)
if resolved == "" {
return "", fmt.Errorf("организация %q не найдена", raw)
}
return resolved, nil
} }
// applyAllocation отправляет modify с массивом vIPConfigure целиком. // applyAllocation отправляет modify с массивом vIPConfigure целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error { func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID.ValueString()) uid := strings.TrimSpace(orgUID)
if uid == "" { if uid == "" {
return fmt.Errorf("org_uid обязателен") return fmt.Errorf("organization обязателен")
} }
if r.client == nil { if r.client == nil {
return fmt.Errorf("клиент не инициализирован") return fmt.Errorf("клиент не инициализирован")
@@ -349,36 +393,21 @@ func formatVipConfigure(items []vipAllocation) string {
return "[" + strings.Join(parts, ",") + "]" return "[" + strings.Join(parts, ",") + "]"
} }
// canonicalVipConfigure приводит пользовательский ввод к каноническому виду. // vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
// Нужен потому, что Terraform `jsonencode` сортирует ключи по алфавиту (`count` раньше `name`), // Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
// а API/HAR дают порядок `name,count`: без канонизации план и Read расходятся → вечный diff. func vipAllocationsEqual(a, b []vipAllocation) bool {
// Невалидный JSON возвращаем как есть — содержательную ошибку выдаст apply. if len(a) != len(b) {
func canonicalVipConfigure(raw string) string { return false
items, err := parseVipConfigure(raw)
if err != nil {
return raw
} }
return formatVipConfigure(items) byName := make(map[string]string, len(b))
} for _, item := range b {
byName[item.Name] = item.Count
// 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())) for _, item := range a {
count, ok := byName[item.Name]
if !ok || count != item.Count {
return false
}
}
return true
} }
@@ -51,32 +51,36 @@ func TestFormatVipConfigure_Canonical(t *testing.T) {
} }
} }
// Канонизация пользовательского ввода: terraform jsonencode сортирует ключи по алфавиту // Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
// (count раньше name), а канон у нас — name,count. Без канонизации план ≠ state → вечный diff // но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
// (баг воспроизведён через `terraform console`, см. NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md). func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
func TestCanonicalVipConfigure_NormalizesJsonencodeForm(t *testing.T) { a, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
raw := `[{"count":"3","name":"internet-ipv4-v1"}]` // так отдаёт jsonencode if err != nil {
want := `[{"name":"internet-ipv4-v1","count":"3"}]` t.Fatalf("неожиданная ошибка: %v", err)
if got := canonicalVipConfigure(raw); got != want { }
t.Fatalf("получено %q, ожидалось %q", got, want) b, err := parseVipConfigure(`[{"count":"3","name":"internet-ipv4-v1"}]`) // так отдаёт jsonencode
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if !vipAllocationsEqual(a, b) {
t.Fatal("значения должны считаться равными несмотря на порядок ключей")
} }
} }
func TestCanonicalVipConfigure_CompactsAndDropsEmptyElements(t *testing.T) { func TestVipAllocationsEqual_Differences(t *testing.T) {
raw := `[ { "count" : "4" , "name" : "internet-ipv4-v1" }, {} ]` base, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
want := `[{"name":"internet-ipv4-v1","count":"4"}]` otherCount, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"2"}]`)
if got := canonicalVipConfigure(raw); got != want { otherName, _ := parseVipConfigure(`[{"name":"internet-antiddos-v1","count":"3"}]`)
t.Fatalf("получено %q, ожидалось %q", got, want) empty, _ := parseVipConfigure(`[{}]`)
}
if got := canonicalVipConfigure(`[{}]`); got != "[]" {
t.Fatalf("для [{}] ожидалось \"[]\", получено %q", got)
}
}
func TestCanonicalVipConfigure_InvalidJSONLeftAsIs(t *testing.T) { if vipAllocationsEqual(base, otherCount) {
raw := `{not json` t.Fatal("разный count должен считаться разными значениями")
if got := canonicalVipConfigure(raw); got != raw { }
t.Fatalf("невалидный JSON должен остаться как есть: %q → %q", raw, got) if vipAllocationsEqual(base, otherName) {
t.Fatal("разное имя ipSpace должно считаться разными значениями")
}
if vipAllocationsEqual(base, empty) {
t.Fatal("пустая аллокация должна отличаться от непустой")
} }
} }