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, массив перезаписывается целиком)
resource "nubes_vc_org_ip_allocation" "org_ip" {
org_uid = var.org_uid
organization = var.organization
vip_configure = jsonencode([
{
@@ -45,7 +45,7 @@ resource "nubes_vc_nsxt_snat" "snat" {
output "allocated_org_ip" {
description = "Выделено внешних IP на организации"
value = {
org_uid = var.org_uid
organization = var.organization
ip_space_name = var.ip_space_name
ip_count = var.ip_count
}
-6
View File
@@ -16,12 +16,6 @@ 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" {
+1 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
nubes = {
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
version = "2.0.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) — **задокументировать** в описании атрибута.
- Раздел 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`: вместо тихой подмены — валидация пустой строки.
**⚠️ Совет Opus (вариант «б», канонизация в plan-modifier) — НЕВЕРЕН.** Plan-modifier не имеет права
менять значение пользовательского атрибута: Terraform отвечает
`Provider produced invalid plan: planned value does not match config value`.
Это правило описано в нашем же сгенерированном коде (`22_vc_nsxt_resource.go`, комментарий в `ModifyPlan`).
Проверено живым `terraform plan` 2026-09-24 (ошибка воспроизведена).
Правильное решение (коммит `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`.
6. ✅ Поправлены/добавлены тесты канонизации (`jsonencode`-форма, пробелы, `[{}]`, невалидный JSON).
7. ⏳ Новый релиз провайдера (2.0.19) с повторной заливкой в `nubes-dev`.
6. ✅ Тесты: смысловое сравнение (порядок ключей, разный count/имя, пустая аллокация).
7. ⚠️ Релиз `2.0.19` залит, но **содержит сломанный plan-modifier** — для работы из реестра нужен `2.0.20`.
+1 -1
View File
@@ -5,7 +5,7 @@
| Стенд | Namespace | Версия | Дата заливки |
|---|---|---|---|
| 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 | (новая нумерация) |
## Как проверить
+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` — строка |
| `keep_on_destroy` | bool, по умолчанию `false` | Не снимать квоту при `destroy` |
Порядок ключей и форматирование не важны — значение канонизируется при планировании
(важно потому, что `jsonencode` сортирует ключи по алфавиту).
Порядок ключей и форматирование не важны — сравнение смысловое (важно потому, что `jsonencode` сортирует
ключи по алфавиту).
Снять аллокацию через `vip_configure` **нельзя** (пустой массив отклоняется): для этого удали ресурс —
тогда отправится обратный `modify` с `count = "0"`.
```hcl
resource "nubes_vc_org_ip_allocation" "this" {
org_uid = var.org_uid
organization = var.organization
vip_configure = jsonencode([
{ name = "internet-ipv4-v1", count = "3" }
@@ -70,6 +70,6 @@ resource "nubes_vc_nsxt_snat" "this" {
Оба ресурса импортируются по UUID родительской услуги:
```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>
```
+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
- Проверенные примеры:
- 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
@@ -36,7 +36,7 @@ type OrgIpAllocationResource struct {
type OrgIpAllocationModel struct {
ID types.String `tfsdk:"id"`
OrgUID types.String `tfsdk:"org_uid"`
Organization types.String `tfsdk:"organization"`
VIPConfigure types.String `tfsdk:"vip_configure"`
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) {
resp.Schema = schema.Schema{
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
@@ -68,9 +68,10 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
stringplanmodifier.UseStateForUnknown(),
},
},
"org_uid": schema.StringAttribute{
Required: true,
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
"organization": schema.StringAttribute{
Required: true,
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
"или её UUID.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
@@ -79,11 +80,8 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
"Порядок ключей и форматирование не важны — значение канонизируется при планировании. " +
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
PlanModifiers: []planmodifier.String{
vipConfigureCanonical(),
},
},
"keep_on_destroy": schema.BoolAttribute{
Optional: true,
@@ -103,12 +101,18 @@ func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.Creat
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())
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)...)
}
@@ -119,12 +123,18 @@ func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.Updat
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())
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)...)
}
@@ -135,8 +145,13 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
return
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
@@ -157,18 +172,19 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
return
}
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если платформа
// ещё не вернула значение (у свежей орги `vIPConfigure: [{}]`), оставляем текущее значение state.
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
raw, ok := live["vIPConfigure"]
if ok {
items, parseErr := parseVipConfigure(raw)
liveItems, parseErr := parseVipConfigure(raw)
if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return
}
if len(items) > 0 {
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
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
}
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
if orgUID == "" || r.client == nil {
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
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) {
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)...)
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 целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID.ValueString())
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID)
if uid == "" {
return fmt.Errorf("org_uid обязателен")
return fmt.Errorf("organization обязателен")
}
if r.client == nil {
return fmt.Errorf("клиент не инициализирован")
@@ -349,36 +393,21 @@ func formatVipConfigure(items []vipAllocation) string {
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
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
func vipAllocationsEqual(a, b []vipAllocation) bool {
if len(a) != len(b) {
return false
}
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
byName := make(map[string]string, len(b))
for _, item := range b {
byName[item.Name] = item.Count
}
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 сортирует ключи по алфавиту
// (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)
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
a, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
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) {
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 TestVipAllocationsEqual_Differences(t *testing.T) {
base, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
otherCount, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"2"}]`)
otherName, _ := parseVipConfigure(`[{"name":"internet-antiddos-v1","count":"3"}]`)
empty, _ := parseVipConfigure(`[{}]`)
func TestCanonicalVipConfigure_InvalidJSONLeftAsIs(t *testing.T) {
raw := `{not json`
if got := canonicalVipConfigure(raw); got != raw {
t.Fatalf("невалидный JSON должен остаться как есть: %q → %q", raw, got)
if vipAllocationsEqual(base, otherCount) {
t.Fatal("разный count должен считаться разными значениями")
}
if vipAllocationsEqual(base, otherName) {
t.Fatal("разное имя ipSpace должно считаться разными значениями")
}
if vipAllocationsEqual(base, empty) {
t.Fatal("пустая аллокация должна отличаться от непустой")
}
}