fix(provider): убран plan-modifier, менявший пользовательское значение (Terraform: planned value must match config); сравнение аллокаций — смысловое в Read
This commit is contained in:
@@ -79,11 +79,8 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
|
|||||||
Required: true,
|
Required: true,
|
||||||
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
||||||
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
|
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
|
||||||
"Порядок ключей и форматирование не важны — значение канонизируется при планировании. " +
|
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
|
||||||
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
|
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
|
||||||
PlanModifiers: []planmodifier.String{
|
|
||||||
vipConfigureCanonical(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"keep_on_destroy": schema.BoolAttribute{
|
"keep_on_destroy": schema.BoolAttribute{
|
||||||
Optional: true,
|
Optional: true,
|
||||||
@@ -157,18 +154,19 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
|
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
|
||||||
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если платформа
|
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
|
||||||
// ещё не вернула значение (у свежей орги `vIPConfigure: [{}]`), оставляем текущее значение state.
|
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
|
||||||
raw, ok := live["vIPConfigure"]
|
raw, ok := live["vIPConfigure"]
|
||||||
if ok {
|
if ok {
|
||||||
items, parseErr := parseVipConfigure(raw)
|
liveItems, parseErr := parseVipConfigure(raw)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(items) > 0 {
|
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
|
||||||
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
|
if !vipAllocationsEqual(liveItems, stateItems) {
|
||||||
|
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,36 +347,21 @@ func formatVipConfigure(items []vipAllocation) string {
|
|||||||
return "[" + strings.Join(parts, ",") + "]"
|
return "[" + strings.Join(parts, ",") + "]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// canonicalVipConfigure приводит пользовательский ввод к каноническому виду.
|
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
|
||||||
// Нужен потому, что Terraform `jsonencode` сортирует ключи по алфавиту (`count` раньше `name`),
|
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
|
||||||
// а API/HAR дают порядок `name,count`: без канонизации план и Read расходятся → вечный diff.
|
func vipAllocationsEqual(a, b []vipAllocation) bool {
|
||||||
// Невалидный JSON возвращаем как есть — содержательную ошибку выдаст apply.
|
if len(a) != len(b) {
|
||||||
func canonicalVipConfigure(raw string) string {
|
return false
|
||||||
items, err := parseVipConfigure(raw)
|
|
||||||
if err != nil {
|
|
||||||
return raw
|
|
||||||
}
|
}
|
||||||
return formatVipConfigure(items)
|
byName := make(map[string]string, len(b))
|
||||||
}
|
for _, item := range b {
|
||||||
|
byName[item.Name] = item.Count
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,32 +51,36 @@ func TestFormatVipConfigure_Canonical(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Канонизация пользовательского ввода: terraform jsonencode сортирует ключи по алфавиту
|
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
|
||||||
// (count раньше name), а канон у нас — name,count. Без канонизации план ≠ state → вечный diff
|
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
|
||||||
// (баг воспроизведён через `terraform console`, см. NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md).
|
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
|
||||||
func TestCanonicalVipConfigure_NormalizesJsonencodeForm(t *testing.T) {
|
a, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
|
||||||
raw := `[{"count":"3","name":"internet-ipv4-v1"}]` // так отдаёт jsonencode
|
if err != nil {
|
||||||
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
|
t.Fatalf("неожиданная ошибка: %v", err)
|
||||||
if got := canonicalVipConfigure(raw); got != want {
|
}
|
||||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
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) {
|
func TestVipAllocationsEqual_Differences(t *testing.T) {
|
||||||
raw := `[ { "count" : "4" , "name" : "internet-ipv4-v1" }, {} ]`
|
base, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
|
||||||
want := `[{"name":"internet-ipv4-v1","count":"4"}]`
|
otherCount, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"2"}]`)
|
||||||
if got := canonicalVipConfigure(raw); got != want {
|
otherName, _ := parseVipConfigure(`[{"name":"internet-antiddos-v1","count":"3"}]`)
|
||||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
empty, _ := parseVipConfigure(`[{}]`)
|
||||||
}
|
|
||||||
if got := canonicalVipConfigure(`[{}]`); got != "[]" {
|
|
||||||
t.Fatalf("для [{}] ожидалось \"[]\", получено %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanonicalVipConfigure_InvalidJSONLeftAsIs(t *testing.T) {
|
if vipAllocationsEqual(base, otherCount) {
|
||||||
raw := `{not json`
|
t.Fatal("разный count должен считаться разными значениями")
|
||||||
if got := canonicalVipConfigure(raw); got != raw {
|
}
|
||||||
t.Fatalf("невалидный JSON должен остаться как есть: %q → %q", raw, got)
|
if vipAllocationsEqual(base, otherName) {
|
||||||
|
t.Fatal("разное имя ipSpace должно считаться разными значениями")
|
||||||
|
}
|
||||||
|
if vipAllocationsEqual(base, empty) {
|
||||||
|
t.Fatal("пустая аллокация должна отличаться от непустой")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user