From 80d82a145a3019bee025a5350a1edccd47d629ad Mon Sep 17 00:00:00 2001 From: Repinoid Date: Thu, 24 Sep 2026 10:22:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(provider):=20=D1=80=D0=B5=D1=81=D1=83?= =?UTF-8?q?=D1=80=D1=81=20nubes=5Fvc=5Fnsxt=5Fsnat=20(modify=20ipSpaceName?= =?UTF-8?q?,=20inverse=20no-needed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources_core/nsxt_snat_resource.go | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 provider/internal/resources_core/nsxt_snat_resource.go diff --git a/provider/internal/resources_core/nsxt_snat_resource.go b/provider/internal/resources_core/nsxt_snat_resource.go new file mode 100644 index 0000000..1ffd52b --- /dev/null +++ b/provider/internal/resources_core/nsxt_snat_resource.go @@ -0,0 +1,258 @@ +package resources_core + +import ( + "context" + "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 = &NsxtSnatResource{} +var _ resource.ResourceWithConfigure = &NsxtSnatResource{} +var _ resource.ResourceWithImportState = &NsxtSnatResource{} + +// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра +// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372). +// +// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет), +// поэтому одним ресурсом «create + modify» в одном apply не сделать. +// +// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md): +// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>"; +// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы). +type NsxtSnatResource struct { + client *core.UniversalClient +} + +type NsxtSnatModel struct { + ID types.String `tfsdk:"id"` + NsxtUID types.String `tfsdk:"nsxt_uid"` + IpSpaceName types.String `tfsdk:"ip_space_name"` + KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"` +} + +// noNeededIpSpace — каноническое значение «SNAT не нужен». +const noNeededIpSpace = "no-needed" + +func NewNsxtSnatResource() resource.Resource { + return &NsxtSnatResource{} +} + +func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat" +} + +func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " + + "Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " + + "Значение `no-needed` выключает SNAT.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "nsxt_uid": schema.StringAttribute{ + Required: true, + MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "ip_space_name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " + + "Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).", + }, + "keep_on_destroy": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " + + "`ipSpaceName = \"no-needed\"`).", + }, + }, + } +} + +func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan NsxtSnatModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString())) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan NsxtSnatModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString())) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state NsxtSnatModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString()) + if nsxtUID == "" || r.client == nil { + return + } + + remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if remove { + resp.State.RemoveResource(ctx) + return + } + + live, err := r.client.GetInstanceStateParams(ctx, nsxtUID) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + // Ключа ipSpaceName нет, пока SNAT ни разу не включали (HAR fresh-create), + // поэтому отсутствие ключа = null. Значение "no-needed" (SNAT выключен) — реальное. + if raw, ok := live["ipSpaceName"]; !ok || strings.TrimSpace(raw) == "" { + state.IpSpaceName = types.StringNull() + } else { + state.IpSpaceName = types.StringValue(strings.TrimSpace(raw)) + } + + state.ID = types.StringValue(nsxtUID) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state NsxtSnatModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString()) + if nsxtUID == "" || r.client == nil { + return + } + + if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() { + resp.Diagnostics.AddWarning( + "SNAT не выключался", + fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID), + ) + return + } + + remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID) + if err != nil { + resp.Diagnostics.AddWarning( + "SNAT не выключался", + fmt.Sprintf("не удалось проверить существование шлюза %s: %s", nsxtUID, err), + ) + return + } + if remove { + resp.Diagnostics.AddWarning( + "SNAT не выключался", + fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID), + ) + return + } + + unlock := r.client.LockInstance(nsxtUID) + defer unlock() + + // Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR). + if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{ + "ipSpaceName": noNeededIpSpace, + }); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + resp.Diagnostics.AddWarning( + "SNAT выключен", + fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace), + ) +} + +func (r *NsxtSnatResource) 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 *NsxtSnatResource) 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("nsxt_uid"), uid)...) +} + +// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции +// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются +// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default +// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает. +func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error { + uid := strings.TrimSpace(nsxtUID.ValueString()) + if uid == "" { + return fmt.Errorf("nsxt_uid обязателен") + } + if r.client == nil { + return fmt.Errorf("клиент не инициализирован") + } + + value := strings.TrimSpace(ipSpaceName.ValueString()) + if value == "" { + value = noNeededIpSpace + } + + unlock := r.client.LockInstance(uid) + defer unlock() + + // ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции, + // а не с live-состоянием инстанса — можно ложно пропустить modify. + return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{ + "ipSpaceName": value, + }) +}