From 8519ba0f44107aa8cf2e88d4c3cef74a70338d1b Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 30 Sep 2026 20:36:47 +0300 Subject: [PATCH] =?UTF-8?q?fix(modifiers):=20ImportState=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82=20Required-=D0=B0?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=B1=D1=83=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: ImportState ставил только id + organization/nsxt_uid, оставляя Required-атрибуты (vip_configure / ip_space_name) в null — Terraform не мог свести импорт с конфигом. - nsxt_snat: importIpSpaceName(live) — live-значение, иначе канон no-needed. - org_ip_allocation: importVipConfigure(live) — канонический JSON из live, иначе []. - Чистые хелперы + тесты TestImportVipConfigure/TestImportIpSpaceName. Проверено: go build ./... OK; go test ./internal/resources_core/... -short -> PASS. Terraform import проверяется владельцем. --- .../resources_core/nsxt_snat_resource.go | 18 ++++++++ .../org_ip_allocation_resource.go | 19 +++++++++ .../resources_core/org_ip_allocation_test.go | 42 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/provider/internal/resources_core/nsxt_snat_resource.go b/provider/internal/resources_core/nsxt_snat_resource.go index 497b630..e096694 100644 --- a/provider/internal/resources_core/nsxt_snat_resource.go +++ b/provider/internal/resources_core/nsxt_snat_resource.go @@ -225,6 +225,24 @@ func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportS 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)...) + + // Required-атрибут ip_space_name нельзя оставить null после импорта: пользователь + // обязан указать его в конфиге. Заполняем live-значением; если SNAT не выставлен — + // каноном «выключено» (no-needed). + var live map[string]string + if r.client != nil { + live, _ = r.client.GetInstanceStateParams(ctx, uid) + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("ip_space_name"), importIpSpaceName(live))...) +} + +// importIpSpaceName выбирает значение ip_space_name для состояния после импорта: +// live-значение, если есть; иначе канон «SNAT выключен» (no-needed). +func importIpSpaceName(live map[string]string) string { + if v := strings.TrimSpace(live["ipSpaceName"]); v != "" { + return v + } + return noNeededIpSpace } // setSnat отправляет modify только с ipSpaceName. Остальные параметры операции diff --git a/provider/internal/resources_core/org_ip_allocation_resource.go b/provider/internal/resources_core/org_ip_allocation_resource.go index db93e69..0533869 100644 --- a/provider/internal/resources_core/org_ip_allocation_resource.go +++ b/provider/internal/resources_core/org_ip_allocation_resource.go @@ -299,6 +299,25 @@ func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource. 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("organization"), uid)...) + + // Required-атрибут vip_configure нельзя оставить null после импорта. + // Заполняем live-значением; если аллокации нет — пустым массивом. + var live map[string]string + if r.client != nil { + live, _ = r.client.GetInstanceStateParams(ctx, uid) + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("vip_configure"), importVipConfigure(live))...) +} + +// importVipConfigure выбирает значение vip_configure для состояния после импорта: +// канонический JSON из live (если есть непустые элементы), иначе пустой массив. +func importVipConfigure(live map[string]string) string { + if raw, ok := live["vIPConfigure"]; ok { + if items, err := parseVipConfigure(raw); err == nil && len(items) > 0 { + return formatVipConfigure(items) + } + } + return "[]" } // resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID. diff --git a/provider/internal/resources_core/org_ip_allocation_test.go b/provider/internal/resources_core/org_ip_allocation_test.go index 296d554..a9ffbb4 100644 --- a/provider/internal/resources_core/org_ip_allocation_test.go +++ b/provider/internal/resources_core/org_ip_allocation_test.go @@ -51,6 +51,48 @@ func TestFormatVipConfigure_Canonical(t *testing.T) { } } +// importVipConfigure/importIpSpaceName — выбор значения Required-атрибутов при импорте +// (B7: Required не должен оставаться null после import). +func TestImportVipConfigure(t *testing.T) { + cases := []struct { + name string + live map[string]string + want string + }{ + {"live-аллокация", map[string]string{"vIPConfigure": `[{"name":"internet-ipv4-v1","count":"3"}]`}, `[{"name":"internet-ipv4-v1","count":"3"}]`}, + {"нет ключа", map[string]string{}, "[]"}, + {"nil", nil, "[]"}, + {"свежая орга [{}]", map[string]string{"vIPConfigure": `[{}]`}, "[]"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := importVipConfigure(tc.live); got != tc.want { + t.Fatalf("получено %q, ожидалось %q", got, tc.want) + } + }) + } +} + +func TestImportIpSpaceName(t *testing.T) { + cases := []struct { + name string + live map[string]string + want string + }{ + {"live-значение", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, "internet-ipv4-v1"}, + {"нет ключа", map[string]string{}, "no-needed"}, + {"nil", nil, "no-needed"}, + {"только пробелы", map[string]string{"ipSpaceName": " "}, "no-needed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := importIpSpaceName(tc.live); got != tc.want { + t.Fatalf("получено %q, ожидалось %q", got, tc.want) + } + }) + } +} + // Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name), // но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф). func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {