fix(provider): канонизирующий plan-modifier для vip_configure (jsonencode сортирует ключи → вечный diff); не писать null в Required; Delete: ошибки API → error
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user