From 621280a5309c56fc2324ffef3dccd1091b475f0c Mon Sep 17 00:00:00 2001 From: Nail Date: Thu, 24 Sep 2026 20:17:32 +0300 Subject: [PATCH] =?UTF-8?q?fix(uuid-case):=20=D0=BD=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20UUID=20?= =?UTF-8?q?=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20JSON=20=E2=80=94=20adopt?= =?UTF-8?q?=20suspended-=D0=B8=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=81=D0=B0?= =?UTF-8?q?=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B5=20(jsonutil=20+=20JsonNorm?= =?UTF-8?q?alize),=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- provider/internal/core/jsonutil/jsonutil.go | 19 +++++- .../internal/core/jsonutil/jsonutil_test.go | 67 +++++++++++++++++++ .../resources_core/json_planmodifier.go | 6 +- .../resources_core/params_compare_test.go | 22 ++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 provider/internal/core/jsonutil/jsonutil_test.go create mode 100644 provider/internal/resources_core/params_compare_test.go diff --git a/provider/internal/core/jsonutil/jsonutil.go b/provider/internal/core/jsonutil/jsonutil.go index ede1782..533bea9 100644 --- a/provider/internal/core/jsonutil/jsonutil.go +++ b/provider/internal/core/jsonutil/jsonutil.go @@ -8,11 +8,28 @@ package jsonutil import ( "bytes" "encoding/json" + "regexp" "sort" "strconv" "strings" ) +// uuidAnywhereRegex — UUID-подобная подстрока внутри произвольного текста +// (например, внутри JSON-строки). Регистр не учитывается. +var uuidAnywhereRegex = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) + +// LowercaseUUIDsInText приводит все UUID-подобные подстроки к lowercase. +// +// Зачем: облако отдаёт один и тот же UUID то в lowercase (vdcUid), то в UPPERCASE +// (nsxtUid у кластера Штурвала), а пользователь может написать в config любой +// регистр. Сравнение plan vs actual обязано игнорировать регистр UUID. +func LowercaseUUIDsInText(value string) string { + if !uuidAnywhereRegex.MatchString(value) { + return value + } + return uuidAnywhereRegex.ReplaceAllStringFunc(value, strings.ToLower) +} + // JSONStringsEquivalent compares JSON strings ignoring whitespace and map key order. func JSONStringsEquivalent(a, b string) bool { na, oka := normalizeJSONIfPossible(a) @@ -107,7 +124,7 @@ func normalizeJSONScalarsToStrings(v interface{}) interface{} { } return out case string: - return val + return LowercaseUUIDsInText(val) case float64: return strconv.FormatFloat(val, 'g', -1, 64) case bool: diff --git a/provider/internal/core/jsonutil/jsonutil_test.go b/provider/internal/core/jsonutil/jsonutil_test.go new file mode 100644 index 0000000..913986e --- /dev/null +++ b/provider/internal/core/jsonutil/jsonutil_test.go @@ -0,0 +1,67 @@ +package jsonutil + +import "testing" + +// FIX(uuid-case) регрессия: облако отдаёт один и тот же UUID то в lowercase (vdcUid), +// то в UPPERCASE (nsxtUid у кластера Штурвала). Сравнение plan vs actual должно +// игнорировать регистр UUID — и для одиночного значения, и внутри JSON. +func TestJSONStringsEquivalentUUIDCase(t *testing.T) { + cases := []struct { + name string + a string + b string + want bool + }{ + { + name: "uuid внутри JSON: разный регистр — эквивалентно", + a: `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","clusterName":"shturval-dev-00"}`, + b: `{"clusterName":"shturval-dev-00","nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6"}`, + want: true, + }, + { + name: "вложенный JSON и смешанный регистр", + a: `{"a":{"b":["2C37FED1-E8F8-4A84-8434-7851C7C8B5D6"]}}`, + b: `{"a":{"b":["2c37fed1-e8f8-4a84-8434-7851c7c8b5d6"]}}`, + want: true, + }, + { + name: "разные UUID — не эквивалентно", + a: `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6"}`, + b: `{"nsxtUid":"86a01033-adac-424f-8296-aa10a001d6ed"}`, + want: false, + }, + { + name: "разные значения не-UUID — не эквивалентно", + a: `{"clusterName":"shturval-dev-00"}`, + b: `{"clusterName":"shturval-dev-01"}`, + want: false, + }, + { + name: "числа и bool остаются различимыми", + a: `{"exVip":true,"count":3}`, + b: `{"exVip":true,"count":"3"}`, + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := JSONStringsEquivalent(tc.a, tc.b); got != tc.want { + t.Fatalf("JSONStringsEquivalent(%s, %s) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestLowercaseUUIDsInText(t *testing.T) { + in := `{"nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6","name":"internet-ipv4-v1"}` + want := `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","name":"internet-ipv4-v1"}` + if got := LowercaseUUIDsInText(in); got != want { + t.Fatalf("LowercaseUUIDsInText() = %s, want %s", got, want) + } + + // Текст без UUID не меняется (в т.ч. имя ipSpace с дефисами и цифрами). + plain := `{"ipSpaceName":"internet-ipv4-v1","qosProfile":"QoS-100Mbit"}` + if got := LowercaseUUIDsInText(plain); got != plain { + t.Fatalf("LowercaseUUIDsInText() изменил текст без UUID: %s", got) + } +} diff --git a/provider/internal/resources_core/json_planmodifier.go b/provider/internal/resources_core/json_planmodifier.go index 3a49f47..3d97c3b 100644 --- a/provider/internal/resources_core/json_planmodifier.go +++ b/provider/internal/resources_core/json_planmodifier.go @@ -17,6 +17,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-nubes/internal/core/jsonutil" ) // jsonNormalizePlanModifier реализует planmodifier.String. @@ -49,5 +51,7 @@ func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planm // Невалидный JSON — оставляем значение без изменений, не прерываем планирование. return } - resp.PlanValue = types.StringValue(buf.String()) + // FIX(uuid-case): UUID-подстроки — к lowercase, чтобы план не расходился с + // состоянием только из-за регистра UUID (облако отдаёт их в разных регистрах). + resp.PlanValue = types.StringValue(jsonutil.LowercaseUUIDsInText(buf.String())) } diff --git a/provider/internal/resources_core/params_compare_test.go b/provider/internal/resources_core/params_compare_test.go new file mode 100644 index 0000000..79a2c4e --- /dev/null +++ b/provider/internal/resources_core/params_compare_test.go @@ -0,0 +1,22 @@ +package resources_core + +import "testing" + +// FIX(uuid-case): регрессия today's bug — adopt приостановленного инстанса Штурвала +// падал с "required params mismatch ... startupConfiguration", потому что UUID +// внутри JSON сравнивался с учётом регистра (plan — lowercase из config, +// actual — UPPERCASE от API). Теперь UUID внутри JSON эквивалентны. +func TestParamsEquivalentUUIDCaseInsideJSON(t *testing.T) { + plan := `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51","clusterName":"shturval-dev-00","exVip":true}` + actual := `{"clusterName":"shturval-dev-00","exVip":true,"nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51"}` + + if !paramsEquivalent(plan, actual) { + t.Fatalf("paramsEquivalent() = false, ожидалось true: регистр UUID внутри JSON не должен влиять") + } + + // Реально другой nsxtUid — по-прежнему не эквивалентно. + other := `{"clusterName":"shturval-dev-00","exVip":true,"nsxtUid":"86a01033-adac-424f-8296-aa10a001d6ed","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51"}` + if paramsEquivalent(plan, other) { + t.Fatalf("paramsEquivalent() = true для другого nsxtUid — неверно") + } +}