fix(uuid-case): нормализация UUID внутри JSON — adopt suspended-инстанса больше не падает на регистре (jsonutil + JsonNormalize), тесты
This commit is contained in:
@@ -8,11 +8,28 @@ package jsonutil
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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.
|
// JSONStringsEquivalent compares JSON strings ignoring whitespace and map key order.
|
||||||
func JSONStringsEquivalent(a, b string) bool {
|
func JSONStringsEquivalent(a, b string) bool {
|
||||||
na, oka := normalizeJSONIfPossible(a)
|
na, oka := normalizeJSONIfPossible(a)
|
||||||
@@ -107,7 +124,7 @@ func normalizeJSONScalarsToStrings(v interface{}) interface{} {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
case string:
|
case string:
|
||||||
return val
|
return LowercaseUUIDsInText(val)
|
||||||
case float64:
|
case float64:
|
||||||
return strconv.FormatFloat(val, 'g', -1, 64)
|
return strconv.FormatFloat(val, 'g', -1, 64)
|
||||||
case bool:
|
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/resource/schema/planmodifier"
|
||||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
|
||||||
|
"terraform-provider-nubes/internal/core/jsonutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jsonNormalizePlanModifier реализует planmodifier.String.
|
// jsonNormalizePlanModifier реализует planmodifier.String.
|
||||||
@@ -49,5 +51,7 @@ func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planm
|
|||||||
// Невалидный JSON — оставляем значение без изменений, не прерываем планирование.
|
// Невалидный JSON — оставляем значение без изменений, не прерываем планирование.
|
||||||
return
|
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 — неверно")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user