feat(provider): ресурс nubes_vc_org_ip_allocation (modify vIPConfigure, uid орги) + тесты нормализации
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
package resources_core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terraform-provider-nubes/internal/core"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var _ resource.Resource = &OrgIpAllocationResource{}
|
||||
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
|
||||
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
|
||||
|
||||
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
|
||||
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
|
||||
//
|
||||
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
|
||||
// (см. docs/TODO/what_not_in_terraform.md) и адресуется здесь по uid.
|
||||
//
|
||||
// Семантика операции — replace всего массива: переданное значение полностью заменяет
|
||||
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
|
||||
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
|
||||
type OrgIpAllocationResource struct {
|
||||
client *core.UniversalClient
|
||||
}
|
||||
|
||||
type OrgIpAllocationModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
OrgUID types.String `tfsdk:"org_uid"`
|
||||
VIPConfigure types.String `tfsdk:"vip_configure"`
|
||||
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||
}
|
||||
|
||||
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
|
||||
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
|
||||
type vipAllocation struct {
|
||||
Name string
|
||||
Count string
|
||||
}
|
||||
|
||||
func NewOrgIpAllocationResource() resource.Resource {
|
||||
return &OrgIpAllocationResource{}
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
|
||||
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
|
||||
"Операция имеет replace-семантику: массив перезаписывается целиком.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"org_uid": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"vip_configure": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
||||
"Значение перезаписывает текущую аллокацию целиком. `count` — строка.",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
JsonNormalize(),
|
||||
},
|
||||
},
|
||||
"keep_on_destroy": schema.BoolAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: booldefault.StaticBool(false),
|
||||
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
|
||||
"`count=0` по каждому элементу).",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
||||
if orgUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
// Организации больше нет — ресурс тоже не нужен.
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
raw, ok := live["vIPConfigure"]
|
||||
if !ok {
|
||||
// Платформа не вернула параметр — считаем, что аллокации нет
|
||||
// (у свежей орги ключ присутствует со значением `[{}]`, что тоже «пусто»).
|
||||
state.VIPConfigure = types.StringNull()
|
||||
} else {
|
||||
items, parseErr := parseVipConfigure(raw)
|
||||
if parseErr != nil {
|
||||
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
state.VIPConfigure = types.StringNull()
|
||||
} else {
|
||||
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
|
||||
}
|
||||
}
|
||||
|
||||
state.ID = types.StringValue(orgUID)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
||||
if orgUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("не удалось проверить существование организации %s: %s", orgUID, err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(orgUID)
|
||||
defer unlock()
|
||||
|
||||
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
|
||||
items := []vipAllocation{}
|
||||
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
|
||||
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
|
||||
items = parsed
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
|
||||
items = parsed
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
|
||||
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
|
||||
zero := make([]vipAllocation, 0, len(items))
|
||||
for _, item := range items {
|
||||
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
|
||||
}
|
||||
|
||||
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
|
||||
"vIPConfigure": formatVipConfigure(zero),
|
||||
}); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Квота IP обнулена",
|
||||
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||
return
|
||||
}
|
||||
r.client = client
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||
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("org_uid"), uid)...)
|
||||
}
|
||||
|
||||
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
|
||||
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
|
||||
uid := strings.TrimSpace(orgUID.ValueString())
|
||||
if uid == "" {
|
||||
return fmt.Errorf("org_uid обязателен")
|
||||
}
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("клиент не инициализирован")
|
||||
}
|
||||
|
||||
items, err := parseVipConfigure(vipConfigure.ValueString())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(uid)
|
||||
defer unlock()
|
||||
|
||||
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
|
||||
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
|
||||
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
|
||||
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
||||
"vIPConfigure": formatVipConfigure(items),
|
||||
})
|
||||
}
|
||||
|
||||
// parseVipConfigure разбирает значение параметра vIPConfigure.
|
||||
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
|
||||
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
|
||||
func parseVipConfigure(raw string) ([]vipAllocation, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
|
||||
}
|
||||
|
||||
out := make([]vipAllocation, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := ""
|
||||
if v, ok := item["name"]; ok && v != nil {
|
||||
name = strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
count := "0"
|
||||
if v, ok := item["count"]; ok && v != nil {
|
||||
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
|
||||
count = parsed
|
||||
}
|
||||
}
|
||||
out = append(out, vipAllocation{Name: name, Count: count})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
|
||||
// (порядок ключей как в HAR; count — строка).
|
||||
func formatVipConfigure(items []vipAllocation) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count))
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package resources_core
|
||||
|
||||
import "testing"
|
||||
|
||||
// Проверяем нормализацию vIPConfigure: свежая орга отдаёт `[{}]` (пустой элемент) —
|
||||
// это «не выделено», а не аллокация (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md).
|
||||
func TestParseVipConfigure_EmptyAndBroken(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"пустая строка", "", 0},
|
||||
{"пустой массив", "[]", 0},
|
||||
{"пустой элемент (свежая орга)", "[{}]", 0},
|
||||
{"только name без count", `[{"name":"internet-ipv4-v1"}]`, 1},
|
||||
{"элемент без name", `[{"count":"3"}]`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseVipConfigure(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if len(got) != tc.want {
|
||||
t.Fatalf("получено %d элементов, ожидалось %d (%+v)", len(got), tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_CountAsString(t *testing.T) {
|
||||
// count у платформы может прийти и числом (в старых стейтах) — нормализуем в строку.
|
||||
got, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":4}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != "4" {
|
||||
t.Fatalf("ожидался count=\"4\", получено %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatVipConfigure_Canonical(t *testing.T) {
|
||||
got := formatVipConfigure([]vipAllocation{{Name: "internet-ipv4-v1", Count: "3"}})
|
||||
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
|
||||
if got != want {
|
||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
||||
}
|
||||
if empty := formatVipConfigure(nil); empty != "[]" {
|
||||
t.Fatalf("для пустого списка ожидалось \"[]\", получено %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_RoundTripIsStable(t *testing.T) {
|
||||
raw := `[{"name":"internet-ipv4-v1","count":"4"}]`
|
||||
items, err := parseVipConfigure(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if again := formatVipConfigure(items); again != raw {
|
||||
t.Fatalf("round-trip не стабилен: %q → %q", raw, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_InvalidJSON(t *testing.T) {
|
||||
if _, err := parseVipConfigure(`{"name":"x"}`); err == nil {
|
||||
t.Fatal("ожидалась ошибка на объект вместо массива")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user