From 807dfde2875d352bff865f69eb9c293dc35bd424 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Thu, 24 Sep 2026 13:57:56 +0300 Subject: [PATCH] =?UTF-8?q?fix(provider):=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=20plan-modifier,=20=D0=BC=D0=B5=D0=BD=D1=8F=D0=B2=D1=88=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D1=81=D0=BA=D0=BE=D0=B5=20=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20(Terraform:=20planned=20value?= =?UTF-8?q?=20must=20match=20config);=20=D1=81=D1=80=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=BB=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B9=20=E2=80=94=20=D1=81=D0=BC=D1=8B=D1=81=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B5=20=D0=B2=20Read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org_ip_allocation_resource.go | 63 +++++++------------ .../resources_core/org_ip_allocation_test.go | 48 +++++++------- 2 files changed, 49 insertions(+), 62 deletions(-) diff --git a/provider/internal/resources_core/org_ip_allocation_resource.go b/provider/internal/resources_core/org_ip_allocation_resource.go index b9fabab..f74a819 100644 --- a/provider/internal/resources_core/org_ip_allocation_resource.go +++ b/provider/internal/resources_core/org_ip_allocation_resource.go @@ -79,11 +79,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, @@ -157,18 +154,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)) } } @@ -349,36 +347,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 } diff --git a/provider/internal/resources_core/org_ip_allocation_test.go b/provider/internal/resources_core/org_ip_allocation_test.go index 7e7bb23..296d554 100644 --- a/provider/internal/resources_core/org_ip_allocation_test.go +++ b/provider/internal/resources_core/org_ip_allocation_test.go @@ -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("пустая аллокация должна отличаться от непустой") } }