diff --git a/provider/internal/resources_core/org_ip_allocation_resource.go b/provider/internal/resources_core/org_ip_allocation_resource.go index ac258de..b9fabab 100644 --- a/provider/internal/resources_core/org_ip_allocation_resource.go +++ b/provider/internal/resources_core/org_ip_allocation_resource.go @@ -78,9 +78,11 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem "vip_configure": schema.StringAttribute{ Required: true, MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " + - "Значение перезаписывает текущую аллокацию целиком. `count` — строка.", + "Значение перезаписывает текущую аллокацию целиком. `count` — строка. " + + "Порядок ключей и форматирование не важны — значение канонизируется при планировании. " + + "Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).", PlanModifiers: []planmodifier.String{ - JsonNormalize(), + vipConfigureCanonical(), }, }, "keep_on_destroy": schema.BoolAttribute{ @@ -155,20 +157,17 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq return } + // ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом, + // иначе Terraform вернёт "Provider produced inconsistent result after apply". Если платформа + // ещё не вернула значение (у свежей орги `vIPConfigure: [{}]`), оставляем текущее значение state. raw, ok := live["vIPConfigure"] - if !ok { - // Платформа не вернула параметр — считаем, что аллокации нет - // (у свежей орги ключ присутствует со значением `[{}]`, что тоже «пусто»). - state.VIPConfigure = types.StringNull() - } else { + if ok { items, parseErr := parseVipConfigure(raw) if parseErr != nil { resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error()) return } - if len(items) == 0 { - state.VIPConfigure = types.StringNull() - } else { + if len(items) > 0 { state.VIPConfigure = types.StringValue(formatVipConfigure(items)) } } @@ -199,10 +198,9 @@ func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.Delet remove, err := ShouldRemoveFromState(ctx, r.client, orgUID) if err != nil { - resp.Diagnostics.AddWarning( - "Аллокация IP не снималась", - fmt.Sprintf("не удалось проверить существование организации %s: %s", orgUID, err), - ) + // Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state, + // а выделенные IP останутся висеть. + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) return } if remove { @@ -338,7 +336,8 @@ func parseVipConfigure(raw string) ([]vipAllocation, error) { } // formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}] -// (порядок ключей как в HAR; count — строка). +// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read, +// иначе план и state расходятся по строке — см. vipConfigureCanonical. func formatVipConfigure(items []vipAllocation) string { if len(items) == 0 { return "[]" @@ -349,3 +348,37 @@ 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 + } + 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 + } + resp.PlanValue = types.StringValue(canonicalVipConfigure(req.PlanValue.ValueString())) +}