fix(uuid-case): нормализация UUID внутри JSON — adopt suspended-инстанса больше не падает на регистре (jsonutil + JsonNormalize), тесты

This commit is contained in:
Nail
2026-09-24 20:17:32 +03:00
parent c9d73450b0
commit 621280a530
4 changed files with 112 additions and 2 deletions
+18 -1
View File
@@ -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:
@@ -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)
}
}
@@ -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()))
}
@@ -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 — неверно")
}
}