fix(modifiers): ImportState заполняет Required-атрибуты

Было: ImportState ставил только id + organization/nsxt_uid, оставляя Required-атрибуты
(vip_configure / ip_space_name) в null — Terraform не мог свести импорт с конфигом.

- nsxt_snat: importIpSpaceName(live) — live-значение, иначе канон no-needed.
- org_ip_allocation: importVipConfigure(live) — канонический JSON из live, иначе [].
- Чистые хелперы + тесты TestImportVipConfigure/TestImportIpSpaceName.

Проверено: go build ./... OK; go test ./internal/resources_core/... -short -> PASS.
Terraform import проверяется владельцем.
This commit is contained in:
Repinoid
2026-09-30 20:36:47 +03:00
parent ea75cac1a8
commit 8519ba0f44
3 changed files with 79 additions and 0 deletions
@@ -225,6 +225,24 @@ func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportS
uid := strings.TrimSpace(req.ID) uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
// Required-атрибут ip_space_name нельзя оставить null после импорта: пользователь
// обязан указать его в конфиге. Заполняем live-значением; если SNAT не выставлен —
// каноном «выключено» (no-needed).
var live map[string]string
if r.client != nil {
live, _ = r.client.GetInstanceStateParams(ctx, uid)
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("ip_space_name"), importIpSpaceName(live))...)
}
// importIpSpaceName выбирает значение ip_space_name для состояния после импорта:
// live-значение, если есть; иначе канон «SNAT выключен» (no-needed).
func importIpSpaceName(live map[string]string) string {
if v := strings.TrimSpace(live["ipSpaceName"]); v != "" {
return v
}
return noNeededIpSpace
} }
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции // setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
@@ -299,6 +299,25 @@ func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.
uid := strings.TrimSpace(req.ID) uid := strings.TrimSpace(req.ID)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
// Required-атрибут vip_configure нельзя оставить null после импорта.
// Заполняем live-значением; если аллокации нет — пустым массивом.
var live map[string]string
if r.client != nil {
live, _ = r.client.GetInstanceStateParams(ctx, uid)
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("vip_configure"), importVipConfigure(live))...)
}
// importVipConfigure выбирает значение vip_configure для состояния после импорта:
// канонический JSON из live (если есть непустые элементы), иначе пустой массив.
func importVipConfigure(live map[string]string) string {
if raw, ok := live["vIPConfigure"]; ok {
if items, err := parseVipConfigure(raw); err == nil && len(items) > 0 {
return formatVipConfigure(items)
}
}
return "[]"
} }
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID. // resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
@@ -51,6 +51,48 @@ func TestFormatVipConfigure_Canonical(t *testing.T) {
} }
} }
// importVipConfigure/importIpSpaceName — выбор значения Required-атрибутов при импорте
// (B7: Required не должен оставаться null после import).
func TestImportVipConfigure(t *testing.T) {
cases := []struct {
name string
live map[string]string
want string
}{
{"live-аллокация", map[string]string{"vIPConfigure": `[{"name":"internet-ipv4-v1","count":"3"}]`}, `[{"name":"internet-ipv4-v1","count":"3"}]`},
{"нет ключа", map[string]string{}, "[]"},
{"nil", nil, "[]"},
{"свежая орга [{}]", map[string]string{"vIPConfigure": `[{}]`}, "[]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := importVipConfigure(tc.live); got != tc.want {
t.Fatalf("получено %q, ожидалось %q", got, tc.want)
}
})
}
}
func TestImportIpSpaceName(t *testing.T) {
cases := []struct {
name string
live map[string]string
want string
}{
{"live-значение", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, "internet-ipv4-v1"},
{"нет ключа", map[string]string{}, "no-needed"},
{"nil", nil, "no-needed"},
{"только пробелы", map[string]string{"ipSpaceName": " "}, "no-needed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := importIpSpaceName(tc.live); got != tc.want {
t.Fatalf("получено %q, ожидалось %q", got, tc.want)
}
})
}
}
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name), // Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф). // но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) { func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {