This commit is contained in:
Repinoid
2026-09-30 21:01:05 +03:00
parent 575f1e29a4
commit 4197a76aba
18 changed files with 2009 additions and 140 deletions
@@ -0,0 +1,169 @@
package core
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"os"
"strings"
"time"
)
// ===== Internal HTTP helpers =====
func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
// User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md).
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
const maxRetries = 3
baseDelay := c.RetryBaseDelay
if baseDelay <= 0 {
baseDelay = 2 * time.Second
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
delay := baseDelay * time.Duration(1<<(attempt-1)) // 2s, 4s, 8s
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, nil, ctx.Err()
}
}
var body io.Reader
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return nil, nil, err
}
body = bytes.NewBuffer(b)
}
reqURL := c.buildURL(path)
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return nil, nil, err
}
// Форсируем новое TCP-соединение для POST/PUT/PATCH: DDoS-Guard может блочить их на keep-alive.
// GET-запросы (поиск, чтение) оставляем на keep-alive — req.Close на них ломает DDoS-Guard.
if method != "GET" {
req.Close = true
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "*/*")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.ApiToken != "" {
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
}
// DEBUG
if isHTTPDebugEnabled() {
reqForDump := req.Clone(req.Context())
reqForDump.Header = sanitizeAuthHeader(req.Header)
dump, _ := httputil.DumpRequestOut(reqForDump, body != nil)
fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump)
}
// DEBUG в файл
if isHTTPDebugEnabled() {
f, _ := os.OpenFile(debugLogPath("nubes_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if f != nil {
fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String())
f.Close()
}
}
resp, err := c.HttpClient.Do(req)
if err != nil {
lastErr = err
// Сетевые ошибки: retry только для идемпотентного GET.
if attempt < maxRetries && method == "GET" {
continue
}
return nil, nil, err
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
if attempt < maxRetries {
continue
}
return nil, nil, err
}
// Retry только для transient-ошибок и только для GET
if resp.StatusCode >= 400 {
if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) {
lastErr = formatAPIError(resp.StatusCode, respBody)
continue
}
return nil, nil, formatAPIError(resp.StatusCode, respBody)
}
return respBody, resp.Header, nil
}
return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr)
}
// isRetryable returns true for transient HTTP errors that can be retried.
func isRetryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || // 429
statusCode == http.StatusServiceUnavailable || // 503
statusCode == http.StatusBadGateway || // 502
statusCode == http.StatusGatewayTimeout // 504
}
func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) {
respBody, headers, err := c.doRequest(ctx, "POST", path, payload)
if err != nil {
return "", err
}
if returnLocation {
if loc := headers.Get("Location"); loc != "" {
return extractUIDFromLocation(loc), nil
}
}
var justId string
if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" {
return justId, nil
}
return "", nil
}
func extractUIDFromLocation(loc string) string {
if loc == "" {
return ""
}
return strings.TrimPrefix(loc, "./")
}
// formatAPIError парсит JSON-ответ API и возвращает читаемое сообщение.
// Если тело не является JSON с полем ERROR — возвращает сырой текст.
func formatAPIError(statusCode int, body []byte) error {
var parsed struct {
Error string `json:"ERROR"`
Detail string `json:"DETAIL"`
}
if json.Unmarshal(body, &parsed) == nil && parsed.Error != "" {
msg := parsed.Error
if d := strings.TrimSpace(parsed.Detail); d != "" {
msg += ": " + d
}
return fmt.Errorf("ошибка API %d: %s", statusCode, msg)
}
return fmt.Errorf("ошибка API %d: %s", statusCode, strings.TrimSpace(string(body)))
}
@@ -0,0 +1,249 @@
package core
import (
"context"
"fmt"
"strings"
"time"
)
// ===== Запуск операций инстанса (params по ID) =====
//
// - RunInstanceOperationUniversal — операция с params по ID (без досылки дефолтов)
// - RunInstanceOperationUniversalWithDefaults — то же + досылка required-дефолтов
// - RunRedeployOperation — redeploy
//
// Операции с params по code — см. operation_run_bycode.go.
// Схема cfsParams (с fallback) — см. operation_cfs.go.
// RunInstanceOperationUniversal runs an available operation (modify/suspend/delete/resume) if possible.
func (c *UniversalClient) RunInstanceOperationUniversal(ctx context.Context, instanceUid string, action string, params map[int]string) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
var opId int
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, action) {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": action,
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции для %s", action)
}
for paramId, value := range params {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
}
}
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
if err != nil {
return err
}
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
}
// RunInstanceOperationUniversalWithDefaults runs an operation and submits required params (including defaults).
func (c *UniversalClient) RunInstanceOperationUniversalWithDefaults(ctx context.Context, instanceUid string, action string, params map[int]string) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
var opId int
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, action) {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": action,
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции для %s", action)
}
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
if err != nil {
return err
}
params, err = c.resolveRefSvcParamValues(ctx, cfsParams, params)
if err != nil {
return err
}
sent := make(map[int]bool)
for paramId, value := range params {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
}
sent[paramId] = true
}
live, liveErr := c.instanceLiveParams(ctx, instanceUid)
if liveErr != nil {
return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr)
}
for _, param := range cfsParams {
if sent[param.SvcOperationCfsParamId] {
continue
}
// Приоритет: live state.params инстанса → paramValue операции → defaultValue.
// paramValue из cfsParams — дефолт формы, не состояние инстанса (см. operation_cfs.go).
// Симметрично runInstanceOperationByCode: если ни одного источника нет — пропускаем
// (иначе уйдёт синтетический "0"/"false"/"[]" и нарушит constraint).
val, hasLive := lookupLiveParam(live, param)
if !hasLive {
if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") &&
(param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") {
continue
}
if param.ParamValue != nil && strings.TrimSpace(*param.ParamValue) != "" {
val = *param.ParamValue
} else if param.DefaultValue != nil {
val = *param.DefaultValue
}
}
val = normalizeUniversalValueV6(val, param)
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
ParamValue: val,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
}
}
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
if err != nil {
return fmt.Errorf("валидация не пройдена: %w", err)
}
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
if err != nil {
return err
}
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
}
// RunRedeployOperation запускает redeploy для сервисов, поддерживающих пересборку из git.
// Если params не nil — отправляет CFS-параметры перед запуском.
func (c *UniversalClient) RunRedeployOperation(ctx context.Context, instanceUid string, timeoutOverride string, params map[int]string) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
opId := 0
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, "redeploy") {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция redeploy недоступна для экземпляра %s", instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": "redeploy",
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию redeploy: %w", err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции redeploy")
}
for paramId, value := range params {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
if _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload); err != nil {
return fmt.Errorf("не удалось установить параметр %d для redeploy: %w", paramId, err)
}
}
if _, _, err := c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}); err != nil {
return err
}
timeout := c.operationTimeoutForContext(ctx, state.ServiceId, "redeploy")
if timeoutOverride != "" {
if d, parseErr := time.ParseDuration(timeoutOverride); parseErr == nil {
timeout = d
}
}
return c.waitForOperationFinish(ctx, opUid, timeout)
}
@@ -0,0 +1,162 @@
package core
import (
"context"
"fmt"
"strings"
)
// RunInstanceOperationUniversalByCode runs an operation using params keyed by code.
// It resolves param codes to IDs via operation manifest, applies defaults, validates, and runs.
func (c *UniversalClient) RunInstanceOperationUniversalByCode(ctx context.Context, instanceUid string, action string, params map[string]string) error {
return c.runInstanceOperationByCode(ctx, instanceUid, action, params, false)
}
// RunInstanceOperationUniversalByIdempotent — то же, но с idempotency pre-check:
// перед run сверяет desired==current и, если ВСЕ поля совпали, пропускает run.
// Применяется для модификаторов с idempotency: check_before_run.
func (c *UniversalClient) RunInstanceOperationUniversalByIdempotent(ctx context.Context, instanceUid string, action string, params map[string]string) error {
return c.runInstanceOperationByCode(ctx, instanceUid, action, params, true)
}
func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instanceUid string, action string, params map[string]string, idempotent bool) error {
state, err := c.GetInstanceState(ctx, instanceUid)
if err != nil {
return err
}
if state.OperationIsPending || state.OperationIsInProgress {
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
return err
}
}
var opId int
for _, op := range state.AvailableOperations {
if strings.EqualFold(op.Operation, action) {
opId = op.SvcOperationId
break
}
}
if opId == 0 {
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
"operation": action,
}
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
if err != nil {
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
}
if opUid == "" {
return fmt.Errorf("не удалось получить UID операции для %s", action)
}
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
if err != nil {
return err
}
// Idempotency pre-check: если все desired уже равны live-значениям — пропускаем run.
// desired = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки.
if idempotent && c.modifierDesiredEqualsCurrent(params, cfsParams) {
return nil
}
codeToParam := make(map[string]universalCfsParam)
for _, p := range cfsParams {
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
codeToParam[key] = p
}
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
codeToParam[key] = p
}
}
paramsByID := map[int]string{}
for code, value := range params {
key := strings.ToLower(strings.TrimSpace(code))
p, ok := codeToParam[key]
if !ok {
return fmt.Errorf("код параметра %s не найден для операции %s", code, action)
}
paramsByID[p.SvcOperationCfsParamId] = value
}
paramsByID, err = c.resolveRefSvcParamValues(ctx, cfsParams, paramsByID)
if err != nil {
return err
}
sent := make(map[int]bool)
for paramId, value := range paramsByID {
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: paramId,
ParamValue: value,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
}
sent[paramId] = true
}
live, liveErr := c.instanceLiveParams(ctx, instanceUid)
if liveErr != nil {
return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr)
}
for _, param := range cfsParams {
if sent[param.SvcOperationCfsParamId] {
continue
}
// Дозаполняем ВСЕ незаданные параметры, чтобы бэкенд modify не трактовал
// пропущенный/null как reset-to-default (иначе частичный payload затирает
// create-поля, см. prompt_for_opus_modifier_null_bug.md).
//
// ПРИОРИТЕТ ИСТОЧНИКА: live state.params инстанса → paramValue операции → defaultValue.
// КРИТИЧНО: paramValue из ?fields=cfsParams — дефолт ФОРМЫ операции, не состояние
// инстанса (HAR/edge_.har: needEnableAVI paramValue="false" при live=true → стирало ALB).
// Если ни live, ни paramValue, ни defaultValue НЕТ — пропускаем (не шлём синтетический
// "0"/"false"/"[]", который может нарушить constraint "integer > 0").
val, hasLive := lookupLiveParam(live, param)
if !hasLive {
if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") &&
(param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") {
continue
}
if param.ParamValue != nil {
val = *param.ParamValue
} else if param.DefaultValue != nil {
val = *param.DefaultValue
}
}
val = normalizeUniversalValueV6(val, param)
pPayload := genericParamReq{
InstanceOperationUid: opUid,
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
ParamValue: val,
}
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
if err != nil {
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
}
}
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
if err != nil {
return fmt.Errorf("валидация не пройдена: %w", err)
}
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
if err != nil {
return err
}
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
}
@@ -0,0 +1,61 @@
package core
import (
"encoding/json"
"strings"
)
// ===== Нормализация значений CFS-параметров =====
// buildMapFixedDefault строит JSON-объект из дефолтов sub-параметров map-fixed.
func buildMapFixedDefault(param universalCfsParam) string {
result := make(map[string]string, len(param.DataDescriptor))
for key, sub := range param.DataDescriptor {
result[key] = sub.DefaultValue
}
b, err := json.Marshal(result)
if err != nil {
return "{}"
}
return string(b)
}
func normalizeUniversalValueV6(val string, param universalCfsParam) string {
trimmed := strings.TrimSpace(val)
if strings.EqualFold(trimmed, "null") {
trimmed = ""
}
if trimmed == "\"\"" {
trimmed = ""
}
// map-fixed с DataDescriptor: если значение пустое или "{}" — строим JSON из дефолтов sub-params.
dataType := strings.ToLower(param.DataType)
if (dataType == "map-fixed" || strings.HasPrefix(dataType, "map")) && len(param.DataDescriptor) > 0 {
if trimmed == "" || trimmed == "{}" {
return buildMapFixedDefault(param)
}
return trimmed
}
if trimmed != "" {
return trimmed
}
nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam)
if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") {
return "[]"
}
if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") {
return "{}"
}
if strings.Contains(dataType, "integer") || strings.Contains(dataType, "int") {
return "0"
}
if strings.Contains(dataType, "boolean") || strings.Contains(dataType, "bool") {
return "false"
}
return trimmed
}
@@ -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
}
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если ключа ещё нет
// (SNAT ни разу не включали, HAR fresh-create) — оставляем текущее значение state.
if raw, ok := live["ipSpaceName"]; ok && strings.TrimSpace(raw) != "" {
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 {
// Реальная ошибка API (не «шлюза нет») — нельзя молча терять SNAT: ресурс уйдёт из state,
// а SNAT останется включённым.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
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("клиент не инициализирован")
}
// Пустую строку молча подменять нельзя (скрытое поведение + риск вечного diff).
// Выключение SNAT — явное каноническое значение "no-needed".
value := strings.TrimSpace(ipSpaceName.ValueString())
if value == "" {
return fmt.Errorf("ip_space_name не может быть пустым: укажите имя ipSpace или %q для выключения SNAT", 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,
})
}
@@ -0,0 +1,413 @@
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: она создаётся один раз вручную в ЛК
// и адресуется здесь по 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"`
Organization types.String `tfsdk:"organization"`
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. " +
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
"Операция имеет replace-семантику: массив перезаписывается целиком.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"organization": schema.StringAttribute{
Required: true,
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
"или её UUID.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"vip_configure": schema.StringAttribute{
Required: true,
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
},
"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
}
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
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
}
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
}
plan.ID = types.StringValue(orgUID)
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
}
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
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
}
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
raw, ok := live["vIPConfigure"]
if ok {
liveItems, parseErr := parseVipConfigure(raw)
if parseErr != nil {
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
return
}
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
if !vipAllocationsEqual(liveItems, stateItems) {
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
}
}
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
}
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
return
}
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
if err != nil {
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
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 {
// Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state,
// а выделенные IP останутся висеть.
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
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("organization"), uid)...)
}
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
// Резолв делает клиент — тем же путём, что сгенерированный nubes_vc_vdc
// (core.ResolveRefSvcParamValue, сравн. 21_vc_vdc_resource.go).
func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, organization types.String) (string, error) {
if r.client == nil {
return "", fmt.Errorf("клиент не инициализирован")
}
raw := strings.TrimSpace(organization.ValueString())
if raw == "" {
return "", fmt.Errorf("organization обязателен")
}
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
if err != nil {
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
}
resolved = strings.TrimSpace(resolved)
if resolved == "" {
return "", fmt.Errorf("организация %q не найдена", raw)
}
return resolved, nil
}
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
uid := strings.TrimSpace(orgUID)
if uid == "" {
return fmt.Errorf("organization обязателен")
}
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":"…"}]
// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read,
// иначе план и state расходятся по строке — см. vipConfigureCanonical.
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, ",") + "]"
}
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
func vipAllocationsEqual(a, b []vipAllocation) bool {
if len(a) != len(b) {
return false
}
byName := make(map[string]string, len(b))
for _, item := range b {
byName[item.Name] = item.Count
}
for _, item := range a {
count, ok := byName[item.Name]
if !ok || count != item.Count {
return false
}
}
return true
}
@@ -0,0 +1,257 @@
package resources_core
import (
"context"
"reflect"
"strings"
"terraform-provider-nubes/internal/core"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type StateField struct {
Code string
}
type InputField struct {
Code string
Field string
Type string
}
var (
typeString = reflect.TypeOf(types.String{})
typeBool = reflect.TypeOf(types.Bool{})
typeInt64 = reflect.TypeOf(types.Int64{})
typeMap = reflect.TypeOf(types.Map{})
typeList = reflect.TypeOf(types.List{})
)
func RefreshResourceState[T any](ctx context.Context, client *core.UniversalClient, instanceID string, serviceID int, state T, outputs []StateField, inputs []InputField) (T, diag.Diagnostics) {
var diags diag.Diagnostics
if client == nil || strings.TrimSpace(instanceID) == "" {
return state, diags
}
out, outDiags := FetchInstanceOutputs(ctx, client, instanceID)
diags.Append(outDiags...)
v := reflect.ValueOf(&state).Elem()
if v.Kind() != reflect.Struct {
return state, diags
}
stateOutMap := map[string]string{}
if !out.StateOut.IsNull() && !out.StateOut.IsUnknown() {
mapped, mapDiags := StringMapFromTypesMap(ctx, out.StateOut)
diags.Append(mapDiags...)
if len(mapped) > 0 {
stateOutMap = mapped
}
}
for _, field := range outputs {
code := strings.TrimSpace(field.Code)
if code == "" {
continue
}
fieldName := toCamel(code)
fv := v.FieldByName(fieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
switch code {
case "state_params":
setFieldValue(fv, out.StateParams)
case "state_out":
setFieldValue(fv, out.StateOut)
case "state_params_flat":
setFieldValue(fv, out.StateParamsFlat)
case "state_out_flat":
setFieldValue(fv, out.StateOutFlat)
case "vault_secrets":
setFieldValue(fv, out.VaultSecrets)
case "vault_url":
setFieldValue(fv, out.VaultUrl)
case "vault_user_path":
setFieldValue(fv, out.VaultUserPath)
case "vault_fields":
setFieldValue(fv, out.VaultFields)
default:
if val, ok := stateOutMap[code]; ok {
setFieldValue(fv, ParseString(val))
}
}
}
paramsMap, paramsDiags := StringMapFromTypesMap(ctx, out.StateParams)
diags.Append(paramsDiags...)
if serviceID > 0 && len(paramsMap) > 0 {
paramsMap, paramsDiags = ResolveRefSvcParamDisplayNames(ctx, client, serviceID, paramsMap)
diags.Append(paramsDiags...)
}
resourceRealm := strings.TrimSpace(paramsMap["resourceRealm"])
if resourceRealm != "" {
outFlatMap, flatDiags := StringMapFromTypesMap(ctx, out.StateOutFlat)
diags.Append(flatDiags...)
if len(outFlatMap) > 0 {
fixed := FixInternalConnectMasterSuffix(outFlatMap, resourceRealm)
if fixed {
out.StateOutFlat, diags = mapValueFrom(ctx, outFlatMap, diags)
fv := v.FieldByName(toCamel("state_out_flat"))
if fv.IsValid() && fv.CanSet() {
setFieldValue(fv, out.StateOutFlat)
}
}
}
}
for _, input := range inputs {
code := strings.TrimSpace(input.Code)
if code == "" {
continue
}
// Поле модели достаём ДО проверки наличия значения в API: оно нужно
// и в ветке "API не вернул код" (см. схлопывание unknown → null ниже).
fieldName := strings.TrimSpace(input.Field)
if fieldName == "" {
fieldName = toCamel(code)
}
fv := v.FieldByName(fieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
value, ok := paramsMap[code]
if !ok {
// ИНВАРИАНТ: Computed-атрибут обязан быть KNOWN после apply/read.
//
// Параметры, которые провайдер читает обратно, объявлены в схеме как
// Optional+Computed (см. helpers.ShouldBeOptionalComputed). Если
// пользователь такой параметр не задал, в плане он = unknown, и именно
// провайдер обязан проставить конкретное значение. Когда платформа
// не вернула код в state_params, единственное корректное конкретное
// значение — null.
//
// Если оставить unknown, Terraform упадёт с
// "Provider produced invalid result object after apply: ... was unknown".
if inputFieldUnknown(fv) {
setInputFieldNull(fv)
}
continue
}
if strings.EqualFold(code, "jsonEnv") && fv.Type() == typeString {
// Preserve planned json_env when API returns equivalent JSON with different ordering.
if planned, ok := fv.Interface().(types.String); ok && !planned.IsNull() && !planned.IsUnknown() {
if JSONStringsEquivalent(planned.ValueString(), value) {
fv.Set(reflect.ValueOf(planned))
continue
}
}
}
switch strings.ToLower(strings.TrimSpace(input.Type)) {
case "bool":
if fv.Type() == typeBool {
fv.Set(reflect.ValueOf(ParseBool(value)))
}
case "int", "int64", "number":
if fv.Type() == typeInt64 {
fv.Set(reflect.ValueOf(ParseInt64(value)))
}
default:
if fv.Type() == typeString {
fv.Set(reflect.ValueOf(ParseString(value)))
}
}
}
return state, diags
}
// inputFieldUnknown сообщает, находится ли поле модели в состоянии unknown.
//
// Зачем: соблюдение инварианта «Computed-атрибут обязан быть known после
// apply/read». Если платформа не вернула значение в state_params, unknown
// оставлять нельзя — его надо схлопнуть в null (см. setInputFieldNull).
func inputFieldUnknown(fv reflect.Value) bool {
switch fv.Type() {
case typeString:
v, ok := fv.Interface().(types.String)
return ok && v.IsUnknown()
case typeBool:
v, ok := fv.Interface().(types.Bool)
return ok && v.IsUnknown()
case typeInt64:
v, ok := fv.Interface().(types.Int64)
return ok && v.IsUnknown()
}
return false
}
// setInputFieldNull записывает в поле модели типизированный null.
//
// Зачем: null — это конкретное (known) значение, в отличие от unknown. Именно
// null приводит состояние Terraform в консистентный вид, когда платформа не
// сообщила значение для read-back параметра.
func setInputFieldNull(fv reflect.Value) {
switch fv.Type() {
case typeString:
fv.Set(reflect.ValueOf(types.StringNull()))
case typeBool:
fv.Set(reflect.ValueOf(types.BoolNull()))
case typeInt64:
fv.Set(reflect.ValueOf(types.Int64Null()))
}
}
func setFieldValue(field reflect.Value, value interface{}) {
switch field.Type() {
case typeString:
switch v := value.(type) {
case types.String:
field.Set(reflect.ValueOf(v))
case string:
field.Set(reflect.ValueOf(ParseString(v)))
}
case typeBool:
if v, ok := value.(types.Bool); ok {
field.Set(reflect.ValueOf(v))
}
case typeInt64:
if v, ok := value.(types.Int64); ok {
field.Set(reflect.ValueOf(v))
}
case typeMap:
if v, ok := value.(types.Map); ok {
field.Set(reflect.ValueOf(v))
}
case typeList:
if v, ok := value.(types.List); ok {
field.Set(reflect.ValueOf(v))
}
}
}
func toCamel(s string) string {
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' })
for i, p := range parts {
if len(p) == 0 {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
out := strings.Join(parts, "")
if out == "" {
return "R"
}
first := rune(out[0])
if (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || first == '_' {
return out
}
return "R" + out
}