diff --git a/provider/internal/resources_core/org_ip_allocation_resource.go b/provider/internal/resources_core/org_ip_allocation_resource.go new file mode 100644 index 0000000..94468df --- /dev/null +++ b/provider/internal/resources_core/org_ip_allocation_resource.go @@ -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, ",") + "]" +} diff --git a/provider/internal/resources_core/org_ip_allocation_test.go b/provider/internal/resources_core/org_ip_allocation_test.go new file mode 100644 index 0000000..5dfcaf9 --- /dev/null +++ b/provider/internal/resources_core/org_ip_allocation_test.go @@ -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("ожидалась ошибка на объект вместо массива") + } +}