From 648db996289d92a83d463830c0095e78137134b5 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Thu, 24 Sep 2026 10:49:15 +0300 Subject: [PATCH] =?UTF-8?q?docs(prompts):=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D1=82=20=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Opus?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B4=D0=B2=D1=83=D1=85=20=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=BE=D0=B2-=D0=BC=D0=BE=D0=B4=D0=B8=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D0=BE=D0=B2,=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B3=20=D0=B8=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...opus_review_modify_resources_2026-09-24.md | 777 ++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md diff --git a/NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md b/NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md new file mode 100644 index 0000000..2c2bc9e --- /dev/null +++ b/NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md @@ -0,0 +1,777 @@ +# Ревью 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, импорт.