From 3d722fb7ee3e511aaa511967ba7f86ee9ddc3284 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Tue, 22 Sep 2026 20:06:49 +0300 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=D1=80=D0=B0=D0=B7=D0=B1?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20client.go=20(1678=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA)=20=D0=BD=D0=B0=2015=20=D0=BC=D0=B5=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8=D1=85=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B7=D0=BE=D0=BD=D0=B0=D0=BC=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- provider/internal/core/client.go | 1605 +---------------- provider/internal/core/errors.go | 22 + provider/internal/core/http.go | 169 ++ provider/internal/core/instance_create.go | 145 ++ provider/internal/core/instance_find.go | 181 ++ provider/internal/core/instance_state.go | 110 ++ provider/internal/core/logging.go | 94 + provider/internal/core/operation_cfs.go | 31 + provider/internal/core/operation_run.go | 235 +++ .../internal/core/operation_run_bycode.go | 137 ++ provider/internal/core/operation_wait.go | 138 ++ provider/internal/core/params.go | 61 + provider/internal/core/refsvc.go | 121 ++ provider/internal/core/refsvc_find.go | 158 ++ provider/internal/core/types.go | 104 ++ provider/internal/core/uuid.go | 29 + 16 files changed, 1740 insertions(+), 1600 deletions(-) create mode 100644 provider/internal/core/errors.go create mode 100644 provider/internal/core/http.go create mode 100644 provider/internal/core/instance_create.go create mode 100644 provider/internal/core/instance_find.go create mode 100644 provider/internal/core/instance_state.go create mode 100644 provider/internal/core/logging.go create mode 100644 provider/internal/core/operation_cfs.go create mode 100644 provider/internal/core/operation_run.go create mode 100644 provider/internal/core/operation_run_bycode.go create mode 100644 provider/internal/core/operation_wait.go create mode 100644 provider/internal/core/params.go create mode 100644 provider/internal/core/refsvc.go create mode 100644 provider/internal/core/refsvc_find.go create mode 100644 provider/internal/core/types.go create mode 100644 provider/internal/core/uuid.go diff --git a/provider/internal/core/client.go b/provider/internal/core/client.go index d94b1c8..5bef8c6 100644 --- a/provider/internal/core/client.go +++ b/provider/internal/core/client.go @@ -1,26 +1,19 @@ // Package core — ядро провайдера: HTTP-клиент Nubes API. // // Содержит: -// - Client — HTTP-клиент с retry, таймаутами, Bearer-токеном -// - Методы GetInstance*, RunOperation*, GetInstanceState* -// - Логику определения proxy vs REST API (isProxyAPI) +// - UniversalClient — клиент с retry, таймаутами, Bearer-токеном +// - HTTP-транспорт (http.go), DTO (types.go), логирование (logging.go) +// - Методы состояния инстанса (instance_state.go), поиска (instance_find.go) +// - Запуск и ожидание операций (operation_run.go, operation_wait.go) +// - Резолв refSvc-параметров (refsvc.go), нормализация значений (params.go) // // ⛔ НЕ ИЗМЕНЯТЬ НИЧЕГО В ЯДРЕ БЕЗ ПРЯМОГО РАЗРЕШЕНИЯ ОПЕРАТОРА. package core import ( - "bytes" - "context" - "encoding/json" - "errors" "fmt" "io" "net/http" - "net/http/httputil" - "net/url" - "os" - "path/filepath" - "regexp" "strings" "sync" "time" @@ -82,1567 +75,10 @@ func (c *UniversalClient) buildURL(path string) string { return c.ApiEndpoint + path } -// ctxKeyLogLevel — ключ для переопределения LogLevel на уровне ресурса через context.WithValue. -type ctxKeyLogLevelType struct{} - -var ctxKeyLogLevel = ctxKeyLogLevelType{} - // userAgent — браузерный, чтобы пройти DDoS-Guard. // Go-http-client по умолчанию блокируется фильтром ddos-guard. const userAgent = "Mozilla/5.0" -func debugLogPath(fileName string) string { - if dir := strings.TrimSpace(os.Getenv("NUBES_DEBUG_DIR")); dir != "" { - return filepath.Join(dir, fileName) - } - return filepath.Join(os.TempDir(), fileName) -} - -func isHTTPDebugEnabled() bool { - return os.Getenv("NUBES_DEBUG_HTTP") == "1" -} - -func sanitizeAuthHeader(h http.Header) http.Header { - cloned := h.Clone() - if cloned.Get("Authorization") != "" { - cloned.Set("Authorization", "Bearer [REDACTED]") - } - return cloned -} - -// CtxWithLogLevel возвращает ctx с переопределённым уровнем логирования. -func CtxWithLogLevel(ctx context.Context, level string) context.Context { - return context.WithValue(ctx, ctxKeyLogLevel, level) -} - -// reTimestamp удаляет временны́е метки вида [2026-03-24T05:07:47.716Z +0.860s] из строк. -var reTimestamp = regexp.MustCompile(`\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[^\]]+\]\s*`) - -// formatStageMsg парсит stageMsg (JSON-массив пар [имя, текст]) и возвращает -// отформатированные строки для вывода. Временны́е метки убираются. -func formatStageMsg(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - // Пытаемся распарсить как [["name","text"],...] - var pairs [][]string - if err := json.Unmarshal([]byte(raw), &pairs); err != nil || len(pairs) == 0 { - return nil - } - // Вычисляем максимальную ширину имени для выравнивания - maxLen := 0 - for _, p := range pairs { - if len(p) >= 1 && len(p[0]) > maxLen { - maxLen = len(p[0]) - } - } - var result []string - for _, p := range pairs { - if len(p) < 2 { - continue - } - name := p[0] - text := reTimestamp.ReplaceAllString(p[1], "") - lines := strings.Split(strings.TrimSpace(text), "\n") - padding := strings.Repeat(" ", maxLen-len(name)) - for i, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if i == 0 { - result = append(result, fmt.Sprintf(" %s:%s %s", name, padding, line)) - } else { - result = append(result, fmt.Sprintf(" %s %s", strings.Repeat(" ", maxLen), line)) - } - } - } - return result -} - -type genericInstanceReq struct { - ServiceId int `json:"serviceId"` - DisplayName string `json:"displayName"` - Descr string `json:"descr"` -} - -type genericOpReq struct { - InstanceUid string `json:"instanceUid"` - Operation string `json:"operation"` -} - -type genericParamReq struct { - InstanceOperationUid string `json:"instanceOperationUid"` - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue string `json:"paramValue"` -} - -// ===== UNIVERSAL FLOW V6 (baseline) ===== - -type universalOpResponse struct { - InstanceOperation universalOperation `json:"instanceOperation"` -} - -type universalOperation struct { - CfsParams []universalCfsParam `json:"cfsParams"` -} - -// universalOpDefaultResponse — схема операции из GET /instanceOperations/default/{opId}. -// Не вычисляет instance-специфичные выражения (getResourceRealmConfig) и не падает -// с 500 на проблемных инстансах (см. docs/DEBUG_REPORT_VC_VDC_500.md). -type universalOpDefaultResponse struct { - SvcOperation universalOperation `json:"svcOperation"` -} - -type universalSubParam struct { - DefaultValue string `json:"defaultValue"` - DataType string `json:"dataType"` - SvcOperationCfsSubparam string `json:"svcOperationCfsSubparam"` - IsRequired bool `json:"isRequired"` -} - -type universalCfsParam struct { - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue *string `json:"paramValue"` - DefaultValue *string `json:"defaultValue"` - IsRequired bool `json:"isRequired"` - DataType string `json:"dataType"` - Name string `json:"name"` - Code string `json:"code"` - Label string `json:"label"` - SvcOperationCfsParam string `json:"svcOperationCfsParam"` - RefSvcId *int `json:"refSvcId"` - DataDescriptor map[string]universalSubParam `json:"dataDescriptor"` -} - -// CreateGenericInstanceUniversalV6 implements the universal flow: -// instances -> instanceOperations -> get cfsParams -> submit params -> validate -> run -func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: c.instanceDescr(), - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - - instanceUid := extractUIDFromLocation(instHeaders.Get("Location")) - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - if instanceUid == "" { - return "", fmt.Errorf("не удалось извлечь instanceUid из ответа (Header: %s)", instHeaders.Get("Location")) - } - - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - - opUid := extractUIDFromLocation(opHeaders.Get("Location")) - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - if opUid == "" { - return "", fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location")) - } - - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - var opDetails universalOpResponse - hasOpDetails := false - if err == nil { - if jsonErr := json.Unmarshal(opDetailsResp, &opDetails); jsonErr != nil { - return "", fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr) - } - hasOpDetails = true - params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.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 - } - - if hasOpDetails { - for _, param := range opDetails.InstanceOperation.CfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - if !param.IsRequired { - continue - } - - val := "" - 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 "", fmt.Errorf("выполнение не удалось: %w", err) - } - - // НЕ МЕНЯТЬ: завершение операции определяется по dtFinish - if err := c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, serviceId, "create")); err != nil { - return "", err - } - if err := c.ensureInstanceCreated(ctx, instanceUid); err != nil { - return "", err - } - - return instanceUid, nil -} - -// 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 -} - -// fetchOperationCfsParams возвращает cfsParams операции. При неудаче живого -// GET /instanceOperations/{opUid}?fields=cfsParams (backend 500 в -// getResourceRealmConfig на проблемных инстансах) берёт схему из -// /instanceOperations/default/{opId}. -func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid string, opId int) ([]universalCfsParam, error) { - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - if err == nil { - var opDetails universalOpResponse - if uerr := json.Unmarshal(opDetailsResp, &opDetails); uerr == nil { - return opDetails.InstanceOperation.CfsParams, nil - } - } - - defResp, _, defErr := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil) - if defErr != nil { - return nil, fmt.Errorf("не удалось получить детали операции: %w", defErr) - } - var def universalOpDefaultResponse - if uerr := json.Unmarshal(defResp, &def); uerr != nil { - return nil, fmt.Errorf("не удалось разобрать детали операции: %w", uerr) - } - return def.SvcOperation.CfsParams, nil -} - -// 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 - } - - for _, param := range cfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - - val := "" - 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)) -} - -// 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) -} - -// Instance state structures - -type ApiOperation struct { - SvcOperationId int `json:"svcOperationId"` - Operation string `json:"operation"` -} - -type InstanceStateResponse struct { - InstanceUid string `json:"instanceUid"` - ServiceId int `json:"serviceId"` - ExplainedStatus string `json:"explainedStatus"` - IsDeleted bool `json:"isDeleted"` - OperationIsInProgress bool `json:"operationIsInProgress"` - OperationIsPending bool `json:"operationIsPending"` - AvailableOperations []ApiOperation `json:"availableOperations"` -} - -// FindInstanceByDisplayName finds an instance by display_name for a given serviceId. -// Если найдено больше одного non-deleted инстанса — возвращает ошибку. -func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, serviceId int, displayName string) (*InstanceStateResponse, error) { - debug := func(format string, args ...interface{}) { - if !isHTTPDebugEnabled() { - return - } - f, err := os.OpenFile(debugLogPath("nubes_find_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return - } - defer f.Close() - fmt.Fprintf(f, format+"\n", args...) - } - var found []InstanceStateResponse - - // Быстрый поиск через search API - searchQuery := url.Values{} - searchQuery.Set("fields", "instanceUid,displayName,serviceId") - searchQuery.Set("page", "1") - searchQuery.Set("pageSize", "100") - searchQuery.Set("search", displayName) - searchQuery.Set("isAuxiliary", "false") - searchQuery.Set("isDeleted", "false") - if serviceId > 0 { - searchQuery.Set("serviceId", fmt.Sprintf("%d", serviceId)) - } - respBody, _, err := c.doRequest(ctx, "GET", "/instances?"+searchQuery.Encode(), nil) - if err != nil { - debug("[FIND-DEBUG] search doRequest err: %v (serviceId=%d, name=%q)", err, serviceId, displayName) - } else { - var res struct { - Results []struct { - InstanceUid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - ServiceId int `json:"serviceId"` - } `json:"results"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - debug("[FIND-DEBUG] search json.Unmarshal err: %v (serviceId=%d, name=%q)", err, serviceId, displayName) - } else { - debug("[FIND-DEBUG] search returned %d results (serviceId=%d, name=%q)", len(res.Results), serviceId, displayName) - for _, item := range res.Results { - matchSvc := item.ServiceId == serviceId - matchName := strings.EqualFold(item.DisplayName, displayName) - debug("[FIND-DEBUG] search item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v", - item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName) - if matchSvc && matchName { - state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid) - if err != nil { - debug("[FIND-DEBUG] GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err) - continue - } - if state == nil { - debug("[FIND-DEBUG] GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid) - continue - } - if isInstanceDeleted(state) { - debug("[FIND-DEBUG] GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus) - continue - } - found = append(found, *state) - } - } - } - } - - // Если быстрый поиск не дал результатов — пагинированный fallback - if len(found) == 0 { - debug("[FIND-DEBUG] search path failed, entering fallback (serviceId=%d, name=%q)", serviceId, displayName) - const maxFallbackPages = 100 - hitPageLimit := false - page := 1 - for { - reqURL := c.buildURL(fmt.Sprintf("/instances?page=%d&size=100", page)) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", userAgent) - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - debug("[FIND-DEBUG] fallback page=%d HttpClient.Do err: %v", page, err) - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - - if resp.StatusCode != 200 { - debug("[FIND-DEBUG] fallback page=%d HTTP %d", page, resp.StatusCode) - return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode) - } - - var res struct { - Results []struct { - InstanceUid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - ServiceId int `json:"serviceId"` - } `json:"results"` - } - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - debug("[FIND-DEBUG] fallback page=%d json.Decode err: %v", page, err) - return nil, err - } - - if len(res.Results) == 0 { - break - } - - debug("[FIND-DEBUG] fallback page=%d has %d results", page, len(res.Results)) - for _, item := range res.Results { - matchSvc := item.ServiceId == serviceId - matchName := strings.EqualFold(item.DisplayName, displayName) - if matchSvc || matchName { - debug("[FIND-DEBUG] fallback item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v", - item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName) - } - if matchSvc && matchName { - state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid) - if err != nil { - debug("[FIND-DEBUG] fallback GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err) - continue - } - if state == nil { - debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid) - continue - } - if isInstanceDeleted(state) { - debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus) - continue - } - found = append(found, *state) - } - } - - page++ - if page > maxFallbackPages { - hitPageLimit = true - break - } - } - - if hitPageLimit && len(found) == 0 { - return nil, fmt.Errorf("поиск инстанса по display_name достиг лимита пагинации (%d страниц); сузьте фильтр или повторите с более точными параметрами", maxFallbackPages) - } - } - - if len(found) == 0 { - return nil, nil - } - if len(found) == 1 { - return &found[0], nil - } - - // Множественные non-deleted инстансы — ошибка - details := make([]string, 0, len(found)) - for _, inst := range found { - details = append(details, fmt.Sprintf(" - %s (статус: %s)", inst.InstanceUid, inst.ExplainedStatus)) - } - return nil, fmt.Errorf( - "обнаружено %d инстансов с именем '%s' (serviceId=%d):\n%s\nНевозможно определить какой adopt-ить. Удалите лишние через ЛК (Управление облаком) или укажите конкретный UUID через 'terraform import'", - len(found), displayName, serviceId, strings.Join(details, "\n"), - ) -} - -func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - state, err := c.getInstanceStateWithRetry(ctx, instanceUid) - if err != nil { - return nil, err - } - if err := validateInstanceStatus(state); err != nil { - return nil, err - } - return state, nil -} - -func isInstanceDeleted(state *InstanceStateResponse) bool { - if state == nil { - return false - } - if state.IsDeleted { - return true - } - return strings.EqualFold(strings.TrimSpace(state.ExplainedStatus), "deleted") -} - -// GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса. -// Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы. -func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - return c.getInstanceStateWithRetry(ctx, instanceUid) -} - -func (c *UniversalClient) getInstanceStateWithRetry(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) - if err != nil { - return nil, err - } - - var res struct { - Instance InstanceStateResponse `json:"instance"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - return nil, err - } - - return &res.Instance, nil -} - -// ===== UNIVERSAL OPERATION WAIT (APPEND-ONLY) ===== -// -// КРИТИЧЕСКИ ВАЖНО: -// 1. Критерий завершения операции — наличие dtFinish. -// 2. Эта логика едина для всех сервисов/операций и является контрактом поведения. -// 3. ЗАПРЕЩЕНО ПРАВИТЬ ЭТОТ КОД БЕЗ ЯВНОГО СОГЛАСОВАНИЯ С ОПЕРАТОРОМ. -// Любые изменения (таймауты, критерии завершения, частота опроса, обработка ошибок) -// должны быть согласованы заранее. -const defaultOperationTimeout = 30 * time.Minute - -// ttyOut открывает /dev/tty для прямого вывода в терминал, минуя перехват terraform. -func ttyOut() *os.File { - if f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil { - return f - } - return os.Stderr -} - -type opStage struct { - InstanceOperationStageUid string `json:"instanceOperationStageUid"` - Stage string `json:"stage"` - IsSuccessful bool `json:"isSuccessful"` - DtFinish *string `json:"dtFinish"` - Duration float64 `json:"duration"` - StageMsg *string `json:"stageMsg"` -} - -type operationStatusResponse struct { - InstanceOperation struct { - DtFinish *string `json:"dtFinish"` - IsSuccessful *bool `json:"isSuccessful"` - ErrorLog *string `json:"errorLog"` - IsInProgress bool `json:"isInProgress"` - IsPending bool `json:"isPending"` - Duration *float64 `json:"duration"` - Stages []opStage `json:"stages"` - } `json:"instanceOperation"` -} - -func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - interval := c.PollInterval - if interval <= 0 { - interval = 5 * time.Second - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - - printedStages := make(map[string]bool) - var lastPendingUID string - - var tty io.Writer - if c.StagesWriter != nil { - tty = c.StagesWriter - } else { - f := ttyOut() - defer f.Close() - tty = f - } - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("операция %s отменена", opUid) - case <-ticker.C: - if time.Now().After(deadline) { - return fmt.Errorf("операция %s не завершилась за установленный таймаут в %.0f секунд", opUid, timeout.Seconds()) - } - - respBody, _, err := c.doRequest(ctx, "GET", - fmt.Sprintf("/instanceOperations/%s?fields=dtFinish,isSuccessful,errorLog,isInProgress,isPending,duration,stages", opUid), nil) - if err != nil { - return fmt.Errorf("не удалось проверить статус операции %s: %w", opUid, err) - } - - var status operationStatusResponse - if err := json.Unmarshal(respBody, &status); err != nil { - return fmt.Errorf("не удалось разобрать статус операции %s: %w", opUid, err) - } - - // Печатаем этапы по мере выполнения — ВСЕГДА, без гейтинга. - // Завершённые (DtFinish != nil): [OK ]/[FAIL] с duration. - // Текущий (DtFinish == nil): [..] один раз при появлении. - for _, stage := range status.InstanceOperation.Stages { - if printedStages[stage.InstanceOperationStageUid] { - continue - } - if stage.DtFinish != nil && *stage.DtFinish != "" { - // Завершённый этап - printedStages[stage.InstanceOperationStageUid] = true - if stage.InstanceOperationStageUid == lastPendingUID { - lastPendingUID = "" - } - status2 := "OK " - if !stage.IsSuccessful { - status2 = "FAIL" - } - fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status2, stage.Stage, stage.Duration) - } else if stage.InstanceOperationStageUid != lastPendingUID { - // Текущий этап — показываем один раз - lastPendingUID = stage.InstanceOperationStageUid - fmt.Fprintf(tty, " [..] %s\n", stage.Stage) - } - } - - // Критерий завершения — dtFinish (НЕ МЕНЯТЬ) - if status.InstanceOperation.DtFinish != nil && strings.TrimSpace(*status.InstanceOperation.DtFinish) != "" { - if status.InstanceOperation.IsSuccessful != nil && !*status.InstanceOperation.IsSuccessful { - if status.InstanceOperation.ErrorLog != nil && strings.TrimSpace(*status.InstanceOperation.ErrorLog) != "" { - return fmt.Errorf("операция %s завершилась с ошибкой: %s", opUid, *status.InstanceOperation.ErrorLog) - } - return fmt.Errorf("операция %s завершилась с ошибкой", opUid) - } - if status.InstanceOperation.Duration != nil { - fmt.Fprintf(tty, " [DONE] %.1f sec\n", *status.InstanceOperation.Duration) - } - return nil - } - } - } -} - -// waitForInstanceIdle waits until no operation is pending/in-progress for the instance. -func (c *UniversalClient) waitForInstanceIdle(ctx context.Context, instanceUid string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("ожидание операции отменено для экземпляра %s", instanceUid) - case <-ticker.C: - if time.Now().After(deadline) { - return fmt.Errorf("экземпляр %s не перешёл в состояние ожидания за установленный таймаут в %.0f секунд", instanceUid, timeout.Seconds()) - } - - state, err := c.GetInstanceState(ctx, instanceUid) - if err != nil { - return fmt.Errorf("не удалось проверить состояние экземпляра %s: %w", instanceUid, err) - } - if !state.OperationIsPending && !state.OperationIsInProgress { - return nil - } - } - } -} - -// 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, "./") -} - -// NOTE(resourceRealm): доступные resourceRealm для сервиса получают через -// GET /resourceRealms/available?svcId= (через index.cfm proxy). -// Пример: svcId=1 (dummy) возвращает results="dummy". - -// NOTE(refSvcId lookup): если параметр операции create ссылается на другой сервис, -// то в cfsParams будет проставлен refSvcId. Это означает, что значение параметра -// должно быть UUID инстанса указанного сервиса. -// Пример: s3UserUid (service_id=13, S3 бакет) имеет refSvcId=12 (S3 Object Storage), -// значит поле s3_user_uid должно быть UUID S3-инстанса, а не имя вида "s3-111805". -// Для S3 (refSvcId=12) запрещен резолв из displayName — принимается только UUID. -// -// ВАЖНО (uuid-case, 2026-03): API облака возвращает UUID всегда в нижнем регистре. -// Пользователь может написать UUID в ВЕРХНЕМ регистре — оба варианта принимаются API. -// Здесь мы нормализуем UUID к lowercase ДЛЯ ОТПРАВКИ В API (это нужно API). -// Однако в state UUID должен сохраняться в том регистре, который написал пользователь -// (иначе plan != state → "Provider produced inconsistent result after apply"). -// Восстановление регистра делается в Create/Read/Update generated resource, см. шаблон -// instanceTemplate в TOOLS/resource-generator/internal/templates/instance.go (блоки "Restore user-provided casing"). - -func (c *UniversalClient) resolveRefSvcParamValues(ctx context.Context, opParams []universalCfsParam, params map[int]string) (map[int]string, error) { - if len(params) == 0 || len(opParams) == 0 { - return params, nil - } - - refById := make(map[int]int) - for _, p := range opParams { - if p.RefSvcId != nil && *p.RefSvcId > 0 { - refById[p.SvcOperationCfsParamId] = *p.RefSvcId - } - } - if len(refById) == 0 { - // Даже без refSvcId — резолвим s3Uid внутри map-fixed (соглашение об именах). - return c.ResolveS3UidInAllMapFixed(params, opParams), nil - } - - resolved := make(map[int]string, len(params)) - for paramId, value := range params { - newValue := value - if refSvcId, ok := refById[paramId]; ok { - if strings.TrimSpace(value) == "" { - newValue = "" - } else if !isUUIDLike(value) { - uid, err := c.findInstanceUidByDisplayNameRefSvc(ctx, refSvcId, value) - if err != nil { - return nil, err - } - if uid == "" { - return nil, fmt.Errorf("не удалось разрешить параметр %d: не найден экземпляр для serviceId=%d displayName=%s", paramId, refSvcId, value) - } - newValue = uid - } else { - newValue = strings.ToLower(value) - } - } else if strings.TrimSpace(value) != "" { - // map-fixed: резолвим s3Uid-подобные uuid sub-params внутри JSON. - newValue = c.resolveS3UidInMapFixed(paramId, value, opParams) - } - resolved[paramId] = newValue - } - - return resolved, nil -} - -// ResolveS3UidInAllMapFixed проходит по всем параметрам и для map-fixed -// резолвит s3Uid-подобные значения в UUID. Используется когда нет ни одного -// параметра с refSvcId (ранний выход resolveRefSvcParamValues). -func (c *UniversalClient) ResolveS3UidInAllMapFixed(params map[int]string, opParams []universalCfsParam) map[int]string { - result := make(map[int]string, len(params)) - for paramId, value := range params { - if strings.TrimSpace(value) != "" && value != "{}" && strings.HasPrefix(strings.TrimSpace(value), "{") { - result[paramId] = c.resolveS3UidInMapFixed(paramId, value, opParams) - } else { - result[paramId] = value - } - } - return result -} - -// resolveS3UidInMapFixed парсит JSON map-fixed-параметра, находит ключи -// по паттерну s3.*uid (case-insensitive) и резолвит значения-не-UUID -// в UUID через S3 (сервис 12). Соглашение об именах: s3Uid, s3_uid, S3Uid → S3. -func (c *UniversalClient) resolveS3UidInMapFixed(paramId int, jsonValue string, opParams []universalCfsParam) string { - if jsonValue == "" || jsonValue == "{}" { - return jsonValue - } - - var obj map[string]interface{} - if err := json.Unmarshal([]byte(jsonValue), &obj); err != nil { - return jsonValue - } - - changed := false - for key, val := range obj { - lowKey := strings.ToLower(key) - if !strings.Contains(lowKey, "s3") || !strings.Contains(lowKey, "uid") { - continue - } - strVal, ok := val.(string) - if !ok || strVal == "" || isUUIDLike(strVal) { - continue - } - uid, err := c.findInstanceUidByDisplayNameRefSvc(context.Background(), 12, strVal) - if err != nil || uid == "" { - continue - } - obj[key] = uid - changed = true - } - - if !changed { - return jsonValue - } - b, err := json.Marshal(obj) - if err != nil { - return jsonValue - } - return string(b) -} - -func (c *UniversalClient) findInstanceUidByDisplayNameRefSvc(ctx context.Context, serviceId int, displayName string) (string, error) { - // Собираем ВСЕ совпадения, затем выбираем лучшее (running > suspended > остальные). - // Deleted инстансы пропускаются — они не должны участвовать в resolve. - type candidate struct { - uid string - status string - } - var candidates []candidate - - page := 1 - for { - path := fmt.Sprintf("/instances?page=%d&size=100", page) - respBody, _, err := c.doRequest(ctx, "GET", path, nil) - if err != nil { - return "", err - } - - var res struct { - Results []struct { - InstanceUid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - ServiceId int `json:"serviceId"` - IsDeleted bool `json:"isDeleted"` - ExplainedStatus string `json:"explainedStatus"` - } `json:"results"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - return "", err - } - - if len(res.Results) == 0 { - break - } - - for _, item := range res.Results { - if item.ServiceId != serviceId || !strings.EqualFold(item.DisplayName, displayName) { - continue - } - // Пропускаем deleted инстансы - if item.IsDeleted { - continue - } - status := strings.ToLower(strings.TrimSpace(item.ExplainedStatus)) - if status == "deleted" { - continue - } - candidates = append(candidates, candidate{uid: item.InstanceUid, status: status}) - } - - page++ - if page > 100 { - break - } - } - - if len(candidates) == 0 { - return "", nil - } - if len(candidates) == 1 { - return candidates[0].uid, nil - } - // Несколько non-deleted совпадений — предпочитаем running - for _, c := range candidates { - if strings.Contains(c.status, "running") || strings.Contains(c.status, "active") { - return c.uid, nil - } - } - // Нет running — предпочитаем suspended - for _, c := range candidates { - if strings.Contains(c.status, "suspend") { - return c.uid, nil - } - } - // Вернуть первый (лучше чем ничего) - return candidates[0].uid, nil -} - -func isUUIDLike(value string) bool { - trimmed := strings.TrimSpace(value) - if len(trimmed) != 36 { - return false - } - for i, r := range trimmed { - switch i { - case 8, 13, 18, 23: - if r != '-' { - return false - } - default: - if !isHexDigit(r) { - return false - } - } - } - return true -} - -func isHexDigit(r rune) bool { - return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') -} - -func (c *UniversalClient) ensureInstanceCreated(ctx context.Context, instanceUid string) error { - state, err := c.GetInstanceState(ctx, instanceUid) - if err != nil { - return fmt.Errorf("не удалось получить состояние экземпляра после создания: %w", err) - } - if state == nil { - return fmt.Errorf("отсутствует состояние экземпляра после создания для %s", instanceUid) - } - if isInstanceDeleted(state) { - return fmt.Errorf("экземпляр %s удален после создания", instanceUid) - } - status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) - if status == "not created" { - return fmt.Errorf("экземпляр %s не создан после завершения операции. Видимо, такой инстанс уже существует в статусе Not Created. %s", instanceUid, formatInstanceStateDetails(state)) - } - return nil -} - -// findInstanceDisplayNameByUidRefSvc resolves display_name by instance UID and service ID. -func (c *UniversalClient) findInstanceDisplayNameByUidRefSvc(ctx context.Context, serviceId int, instanceUid string) (string, error) { - page := 1 - for { - path := fmt.Sprintf("/instances?page=%d&size=100", page) - respBody, _, err := c.doRequest(ctx, "GET", path, nil) - if err != nil { - return "", err - } - - var res struct { - Results []struct { - InstanceUid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - ServiceId int `json:"serviceId"` - } `json:"results"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - return "", err - } - - if len(res.Results) == 0 { - break - } - - for _, item := range res.Results { - if item.ServiceId == serviceId && strings.EqualFold(item.InstanceUid, instanceUid) { - return item.DisplayName, nil - } - } - - page++ - if page > 100 { - break - } - } - - return "", nil -} - -// getInstanceDisplayNameByUidRefSvc fetches display_name by instance UID and checks service ID. -func (c *UniversalClient) getInstanceDisplayNameByUidRefSvc(ctx context.Context, serviceId int, instanceUid string) (string, error) { - if strings.TrimSpace(instanceUid) == "" { - return "", nil - } - - respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) - if err != nil { - return "", err - } - - var res struct { - Instance struct { - InstanceUid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - ServiceId int `json:"serviceId"` - } `json:"instance"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - return "", err - } - if strings.TrimSpace(res.Instance.InstanceUid) == "" { - return "", nil - } - if serviceId > 0 && res.Instance.ServiceId != serviceId { - return "", nil - } - - return res.Instance.DisplayName, nil -} - -func validateInstanceStatus(state *InstanceStateResponse) error { - if state == nil { - return fmt.Errorf("отсутствует состояние экземпляра") - } - if isInstanceDeleted(state) { - return &instanceDeletedError{InstanceUID: state.InstanceUid} - } - if state.OperationIsPending || state.OperationIsInProgress { - return fmt.Errorf("экземпляр %s не готов: операция в ожидании", state.InstanceUid) - } - status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) - if strings.Contains(status, "not created") { - return fmt.Errorf("экземпляр %s не создан. Видимо, такой инстанс уже существует в статусе Not Created. %s", state.InstanceUid, formatInstanceStateDetails(state)) - } - if strings.Contains(status, "pending") { - return fmt.Errorf("экземпляр %s в ожидании. %s", state.InstanceUid, formatInstanceStateDetails(state)) - } - if strings.Contains(status, "failed") || strings.Contains(status, "error") { - return fmt.Errorf("экземпляр %s завершился с ошибкой: %s. %s", state.InstanceUid, state.ExplainedStatus, formatInstanceStateDetails(state)) - } - return nil -} - -func formatInstanceStateDetails(state *InstanceStateResponse) string { - if state == nil { - return "details: state=nil" - } - statusRaw := strings.TrimSpace(state.ExplainedStatus) - statusNorm := strings.ToLower(statusRaw) - return "details: instance_uid=" + state.InstanceUid + ", status=\"" + statusNorm + "\", status_raw=\"" + statusRaw + "\", operation_pending=" + formatBool(state.OperationIsPending) + ", operation_in_progress=" + formatBool(state.OperationIsInProgress) -} - -func formatBool(value bool) string { - if value { - return "true" - } - return "false" -} - -// 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 { - 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 - } - - 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 - } - - for _, param := range cfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - // Дозаполняем ВСЕ незаданные параметры их текущим live-значением - // (ParamValue из GET ?fields=cfsParams), а не только required. - // Бэкенд для modify трактует пропущенный/null как reset-to-default, - // из-за чего частичный payload затирал create-поля (см. prompt_for_opus_modifier_null_bug.md). - // Поведение выровнено под RunInstanceOperationUniversalWithDefaults. - // НО: если ни live-значения, ни дефолта НЕТ — пропускаем (не шлём синтетический - // "0"/"false"/"[]", который может нарушить constraint "integer > 0"). - if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") && - (param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") { - continue - } - val := "" - 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)) -} - // instanceDescr returns a stable description with provider version when available. func (c *UniversalClient) instanceDescr() string { version := strings.TrimSpace(c.ProviderVersion) @@ -1651,34 +87,3 @@ func (c *UniversalClient) instanceDescr() string { } return fmt.Sprintf("Создано через Nubes Terraform Universal Provider %s", version) } - -// instanceDeletedError marks deleted instances to be treated as not found. -type instanceDeletedError struct { - InstanceUID string -} - -func (e *instanceDeletedError) Error() string { - return fmt.Sprintf("экземпляр %s удален", e.InstanceUID) -} - -func isInstanceDeletedError(err error) bool { - var e *instanceDeletedError - return errors.As(err, &e) -} - -// 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))) -} diff --git a/provider/internal/core/errors.go b/provider/internal/core/errors.go new file mode 100644 index 0000000..fb41158 --- /dev/null +++ b/provider/internal/core/errors.go @@ -0,0 +1,22 @@ +package core + +import ( + "errors" + "fmt" +) + +// ===== Ошибки ядра ===== + +// instanceDeletedError marks deleted instances to be treated as not found. +type instanceDeletedError struct { + InstanceUID string +} + +func (e *instanceDeletedError) Error() string { + return fmt.Sprintf("экземпляр %s удален", e.InstanceUID) +} + +func isInstanceDeletedError(err error) bool { + var e *instanceDeletedError + return errors.As(err, &e) +} diff --git a/provider/internal/core/http.go b/provider/internal/core/http.go new file mode 100644 index 0000000..ba5f8c5 --- /dev/null +++ b/provider/internal/core/http.go @@ -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))) +} diff --git a/provider/internal/core/instance_create.go b/provider/internal/core/instance_create.go new file mode 100644 index 0000000..ac1fe0e --- /dev/null +++ b/provider/internal/core/instance_create.go @@ -0,0 +1,145 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" +) + +// CreateGenericInstanceUniversalV6 implements the universal flow: +// instances -> instanceOperations -> get cfsParams -> submit params -> validate -> run +func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { + instPayload := genericInstanceReq{ + ServiceId: serviceId, + DisplayName: displayName, + Descr: c.instanceDescr(), + } + + instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) + if err != nil { + return "", err + } + + instanceUid := extractUIDFromLocation(instHeaders.Get("Location")) + if instanceUid == "" { + var instResult struct { + InstanceUid string `json:"instanceUid"` + } + if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { + instanceUid = instResult.InstanceUid + } else { + var justId string + if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { + instanceUid = justId + } + } + } + if instanceUid == "" { + return "", fmt.Errorf("не удалось извлечь instanceUid из ответа (Header: %s)", instHeaders.Get("Location")) + } + + opPayload := genericOpReq{ + InstanceUid: instanceUid, + Operation: "create", + } + + opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) + if err != nil { + return "", err + } + + opUid := extractUIDFromLocation(opHeaders.Get("Location")) + if opUid == "" { + var opResult struct { + InstanceOperationUid string `json:"instanceOperationUid"` + } + if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { + opUid = opResult.InstanceOperationUid + } else { + var justId string + if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { + opUid = justId + } + } + } + if opUid == "" { + return "", fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location")) + } + + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + var opDetails universalOpResponse + hasOpDetails := false + if err == nil { + if jsonErr := json.Unmarshal(opDetailsResp, &opDetails); jsonErr != nil { + return "", fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr) + } + hasOpDetails = true + params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.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 + } + + if hasOpDetails { + for _, param := range opDetails.InstanceOperation.CfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + if !param.IsRequired { + continue + } + + val := "" + 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 "", fmt.Errorf("выполнение не удалось: %w", err) + } + + // НЕ МЕНЯТЬ: завершение операции определяется по dtFinish + if err := c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, serviceId, "create")); err != nil { + return "", err + } + if err := c.ensureInstanceCreated(ctx, instanceUid); err != nil { + return "", err + } + + return instanceUid, nil +} diff --git a/provider/internal/core/instance_find.go b/provider/internal/core/instance_find.go new file mode 100644 index 0000000..34df94a --- /dev/null +++ b/provider/internal/core/instance_find.go @@ -0,0 +1,181 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strings" +) + +// FindInstanceByDisplayName finds an instance by display_name for a given serviceId. +// Если найдено больше одного non-deleted инстанса — возвращает ошибку. +func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, serviceId int, displayName string) (*InstanceStateResponse, error) { + debug := func(format string, args ...interface{}) { + if !isHTTPDebugEnabled() { + return + } + f, err := os.OpenFile(debugLogPath("nubes_find_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, format+"\n", args...) + } + var found []InstanceStateResponse + + // Быстрый поиск через search API + searchQuery := url.Values{} + searchQuery.Set("fields", "instanceUid,displayName,serviceId") + searchQuery.Set("page", "1") + searchQuery.Set("pageSize", "100") + searchQuery.Set("search", displayName) + searchQuery.Set("isAuxiliary", "false") + searchQuery.Set("isDeleted", "false") + if serviceId > 0 { + searchQuery.Set("serviceId", fmt.Sprintf("%d", serviceId)) + } + respBody, _, err := c.doRequest(ctx, "GET", "/instances?"+searchQuery.Encode(), nil) + if err != nil { + debug("[FIND-DEBUG] search doRequest err: %v (serviceId=%d, name=%q)", err, serviceId, displayName) + } else { + var res struct { + Results []struct { + InstanceUid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceId int `json:"serviceId"` + } `json:"results"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + debug("[FIND-DEBUG] search json.Unmarshal err: %v (serviceId=%d, name=%q)", err, serviceId, displayName) + } else { + debug("[FIND-DEBUG] search returned %d results (serviceId=%d, name=%q)", len(res.Results), serviceId, displayName) + for _, item := range res.Results { + matchSvc := item.ServiceId == serviceId + matchName := strings.EqualFold(item.DisplayName, displayName) + debug("[FIND-DEBUG] search item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v", + item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName) + if matchSvc && matchName { + state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid) + if err != nil { + debug("[FIND-DEBUG] GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err) + continue + } + if state == nil { + debug("[FIND-DEBUG] GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid) + continue + } + if isInstanceDeleted(state) { + debug("[FIND-DEBUG] GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus) + continue + } + found = append(found, *state) + } + } + } + } + + // Если быстрый поиск не дал результатов — пагинированный fallback + if len(found) == 0 { + debug("[FIND-DEBUG] search path failed, entering fallback (serviceId=%d, name=%q)", serviceId, displayName) + const maxFallbackPages = 100 + hitPageLimit := false + page := 1 + for { + reqURL := c.buildURL(fmt.Sprintf("/instances?page=%d&size=100", page)) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + debug("[FIND-DEBUG] fallback page=%d HttpClient.Do err: %v", page, err) + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + + if resp.StatusCode != 200 { + debug("[FIND-DEBUG] fallback page=%d HTTP %d", page, resp.StatusCode) + return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode) + } + + var res struct { + Results []struct { + InstanceUid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceId int `json:"serviceId"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + debug("[FIND-DEBUG] fallback page=%d json.Decode err: %v", page, err) + return nil, err + } + + if len(res.Results) == 0 { + break + } + + debug("[FIND-DEBUG] fallback page=%d has %d results", page, len(res.Results)) + for _, item := range res.Results { + matchSvc := item.ServiceId == serviceId + matchName := strings.EqualFold(item.DisplayName, displayName) + if matchSvc || matchName { + debug("[FIND-DEBUG] fallback item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v", + item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName) + } + if matchSvc && matchName { + state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid) + if err != nil { + debug("[FIND-DEBUG] fallback GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err) + continue + } + if state == nil { + debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid) + continue + } + if isInstanceDeleted(state) { + debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus) + continue + } + found = append(found, *state) + } + } + + page++ + if page > maxFallbackPages { + hitPageLimit = true + break + } + } + + if hitPageLimit && len(found) == 0 { + return nil, fmt.Errorf("поиск инстанса по display_name достиг лимита пагинации (%d страниц); сузьте фильтр или повторите с более точными параметрами", maxFallbackPages) + } + } + + if len(found) == 0 { + return nil, nil + } + if len(found) == 1 { + return &found[0], nil + } + + // Множественные non-deleted инстансы — ошибка + details := make([]string, 0, len(found)) + for _, inst := range found { + details = append(details, fmt.Sprintf(" - %s (статус: %s)", inst.InstanceUid, inst.ExplainedStatus)) + } + return nil, fmt.Errorf( + "обнаружено %d инстансов с именем '%s' (serviceId=%d):\n%s\nНевозможно определить какой adopt-ить. Удалите лишние через ЛК (Управление облаком) или укажите конкретный UUID через 'terraform import'", + len(found), displayName, serviceId, strings.Join(details, "\n"), + ) +} diff --git a/provider/internal/core/instance_state.go b/provider/internal/core/instance_state.go new file mode 100644 index 0000000..3c00e1b --- /dev/null +++ b/provider/internal/core/instance_state.go @@ -0,0 +1,110 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// ===== Чтение и валидация состояния инстанса ===== + +func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { + state, err := c.getInstanceStateWithRetry(ctx, instanceUid) + if err != nil { + return nil, err + } + if err := validateInstanceStatus(state); err != nil { + return nil, err + } + return state, nil +} + +func isInstanceDeleted(state *InstanceStateResponse) bool { + if state == nil { + return false + } + if state.IsDeleted { + return true + } + return strings.EqualFold(strings.TrimSpace(state.ExplainedStatus), "deleted") +} + +// GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса. +// Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы. +func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { + return c.getInstanceStateWithRetry(ctx, instanceUid) +} + +func (c *UniversalClient) getInstanceStateWithRetry(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { + respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) + if err != nil { + return nil, err + } + + var res struct { + Instance InstanceStateResponse `json:"instance"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return nil, err + } + + return &res.Instance, nil +} + +func (c *UniversalClient) ensureInstanceCreated(ctx context.Context, instanceUid string) error { + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return fmt.Errorf("не удалось получить состояние экземпляра после создания: %w", err) + } + if state == nil { + return fmt.Errorf("отсутствует состояние экземпляра после создания для %s", instanceUid) + } + if isInstanceDeleted(state) { + return fmt.Errorf("экземпляр %s удален после создания", instanceUid) + } + status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) + if status == "not created" { + return fmt.Errorf("экземпляр %s не создан после завершения операции. Видимо, такой инстанс уже существует в статусе Not Created. %s", instanceUid, formatInstanceStateDetails(state)) + } + return nil +} + +func validateInstanceStatus(state *InstanceStateResponse) error { + if state == nil { + return fmt.Errorf("отсутствует состояние экземпляра") + } + if isInstanceDeleted(state) { + return &instanceDeletedError{InstanceUID: state.InstanceUid} + } + if state.OperationIsPending || state.OperationIsInProgress { + return fmt.Errorf("экземпляр %s не готов: операция в ожидании", state.InstanceUid) + } + status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) + if strings.Contains(status, "not created") { + return fmt.Errorf("экземпляр %s не создан. Видимо, такой инстанс уже существует в статусе Not Created. %s", state.InstanceUid, formatInstanceStateDetails(state)) + } + if strings.Contains(status, "pending") { + return fmt.Errorf("экземпляр %s в ожидании. %s", state.InstanceUid, formatInstanceStateDetails(state)) + } + if strings.Contains(status, "failed") || strings.Contains(status, "error") { + return fmt.Errorf("экземпляр %s завершился с ошибкой: %s. %s", state.InstanceUid, state.ExplainedStatus, formatInstanceStateDetails(state)) + } + return nil +} + +func formatInstanceStateDetails(state *InstanceStateResponse) string { + if state == nil { + return "details: state=nil" + } + statusRaw := strings.TrimSpace(state.ExplainedStatus) + statusNorm := strings.ToLower(statusRaw) + return "details: instance_uid=" + state.InstanceUid + ", status=\"" + statusNorm + "\", status_raw=\"" + statusRaw + "\", operation_pending=" + formatBool(state.OperationIsPending) + ", operation_in_progress=" + formatBool(state.OperationIsInProgress) +} + +func formatBool(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/provider/internal/core/logging.go b/provider/internal/core/logging.go new file mode 100644 index 0000000..ed45eaa --- /dev/null +++ b/provider/internal/core/logging.go @@ -0,0 +1,94 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +// ===== Логирование и debug-утилиты ===== +// +// - ctxKeyLogLevel / CtxWithLogLevel — переопределение LogLevel на уровне ресурса +// - debugLogPath / isHTTPDebugEnabled — файловый debug HTTP (NUBES_DEBUG_HTTP=1) +// - sanitizeAuthHeader — маскирование Authorization в дампах +// - formatStageMsg — форматирование stageMsg операции + +// ctxKeyLogLevel — ключ для переопределения LogLevel на уровне ресурса через context.WithValue. +type ctxKeyLogLevelType struct{} + +var ctxKeyLogLevel = ctxKeyLogLevelType{} + +func debugLogPath(fileName string) string { + if dir := strings.TrimSpace(os.Getenv("NUBES_DEBUG_DIR")); dir != "" { + return filepath.Join(dir, fileName) + } + return filepath.Join(os.TempDir(), fileName) +} + +func isHTTPDebugEnabled() bool { + return os.Getenv("NUBES_DEBUG_HTTP") == "1" +} + +func sanitizeAuthHeader(h http.Header) http.Header { + cloned := h.Clone() + if cloned.Get("Authorization") != "" { + cloned.Set("Authorization", "Bearer [REDACTED]") + } + return cloned +} + +// CtxWithLogLevel возвращает ctx с переопределённым уровнем логирования. +func CtxWithLogLevel(ctx context.Context, level string) context.Context { + return context.WithValue(ctx, ctxKeyLogLevel, level) +} + +// reTimestamp удаляет временны́е метки вида [2026-03-24T05:07:47.716Z +0.860s] из строк. +var reTimestamp = regexp.MustCompile(`\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[^\]]+\]\s*`) + +// formatStageMsg парсит stageMsg (JSON-массив пар [имя, текст]) и возвращает +// отформатированные строки для вывода. Временны́е метки убираются. +func formatStageMsg(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + // Пытаемся распарсить как [["name","text"],...] + var pairs [][]string + if err := json.Unmarshal([]byte(raw), &pairs); err != nil || len(pairs) == 0 { + return nil + } + // Вычисляем максимальную ширину имени для выравнивания + maxLen := 0 + for _, p := range pairs { + if len(p) >= 1 && len(p[0]) > maxLen { + maxLen = len(p[0]) + } + } + var result []string + for _, p := range pairs { + if len(p) < 2 { + continue + } + name := p[0] + text := reTimestamp.ReplaceAllString(p[1], "") + lines := strings.Split(strings.TrimSpace(text), "\n") + padding := strings.Repeat(" ", maxLen-len(name)) + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if i == 0 { + result = append(result, fmt.Sprintf(" %s:%s %s", name, padding, line)) + } else { + result = append(result, fmt.Sprintf(" %s %s", strings.Repeat(" ", maxLen), line)) + } + } + } + return result +} diff --git a/provider/internal/core/operation_cfs.go b/provider/internal/core/operation_cfs.go new file mode 100644 index 0000000..eb8840b --- /dev/null +++ b/provider/internal/core/operation_cfs.go @@ -0,0 +1,31 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" +) + +// fetchOperationCfsParams возвращает cfsParams операции. При неудаче живого +// GET /instanceOperations/{opUid}?fields=cfsParams (backend 500 в +// getResourceRealmConfig на проблемных инстансах) берёт схему из +// /instanceOperations/default/{opId}. +func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid string, opId int) ([]universalCfsParam, error) { + opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) + if err == nil { + var opDetails universalOpResponse + if uerr := json.Unmarshal(opDetailsResp, &opDetails); uerr == nil { + return opDetails.InstanceOperation.CfsParams, nil + } + } + + defResp, _, defErr := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil) + if defErr != nil { + return nil, fmt.Errorf("не удалось получить детали операции: %w", defErr) + } + var def universalOpDefaultResponse + if uerr := json.Unmarshal(defResp, &def); uerr != nil { + return nil, fmt.Errorf("не удалось разобрать детали операции: %w", uerr) + } + return def.SvcOperation.CfsParams, nil +} diff --git a/provider/internal/core/operation_run.go b/provider/internal/core/operation_run.go new file mode 100644 index 0000000..9255d47 --- /dev/null +++ b/provider/internal/core/operation_run.go @@ -0,0 +1,235 @@ +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 + } + + for _, param := range cfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + val := "" + 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)) +} + +// 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) +} diff --git a/provider/internal/core/operation_run_bycode.go b/provider/internal/core/operation_run_bycode.go new file mode 100644 index 0000000..1d6962f --- /dev/null +++ b/provider/internal/core/operation_run_bycode.go @@ -0,0 +1,137 @@ +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 { + 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 + } + + 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 + } + + for _, param := range cfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + // Дозаполняем ВСЕ незаданные параметры их текущим live-значением + // (ParamValue из GET ?fields=cfsParams), а не только required. + // Бэкенд для modify трактует пропущенный/null как reset-to-default, + // из-за чего частичный payload затирал create-поля (см. prompt_for_opus_modifier_null_bug.md). + // Поведение выровнено под RunInstanceOperationUniversalWithDefaults. + // НО: если ни live-значения, ни дефолта НЕТ — пропускаем (не шлём синтетический + // "0"/"false"/"[]", который может нарушить constraint "integer > 0"). + if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") && + (param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") { + continue + } + val := "" + 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)) +} diff --git a/provider/internal/core/operation_wait.go b/provider/internal/core/operation_wait.go new file mode 100644 index 0000000..d79383f --- /dev/null +++ b/provider/internal/core/operation_wait.go @@ -0,0 +1,138 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" +) + +// ===== Ожидание операций (APPEND-ONLY) ===== +// +// КРИТИЧЕСКИ ВАЖНО: +// 1. Критерий завершения операции — наличие dtFinish. +// 2. Эта логика едина для всех сервисов/операций и является контрактом поведения. +// 3. ЗАПРЕЩЕНО ПРАВИТЬ ЭТОТ КОД БЕЗ ЯВНОГО СОГЛАСОВАНИЯ С ОПЕРАТОРОМ. +// Любые изменения (таймауты, критерии завершения, частота опроса, обработка ошибок) +// должны быть согласованы заранее. +const defaultOperationTimeout = 30 * time.Minute + +// ttyOut открывает /dev/tty для прямого вывода в терминал, минуя перехват terraform. +func ttyOut() *os.File { + if f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil { + return f + } + return os.Stderr +} + +func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + interval := c.PollInterval + if interval <= 0 { + interval = 5 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + printedStages := make(map[string]bool) + var lastPendingUID string + + var tty io.Writer + if c.StagesWriter != nil { + tty = c.StagesWriter + } else { + f := ttyOut() + defer f.Close() + tty = f + } + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("операция %s отменена", opUid) + case <-ticker.C: + if time.Now().After(deadline) { + return fmt.Errorf("операция %s не завершилась за установленный таймаут в %.0f секунд", opUid, timeout.Seconds()) + } + + respBody, _, err := c.doRequest(ctx, "GET", + fmt.Sprintf("/instanceOperations/%s?fields=dtFinish,isSuccessful,errorLog,isInProgress,isPending,duration,stages", opUid), nil) + if err != nil { + return fmt.Errorf("не удалось проверить статус операции %s: %w", opUid, err) + } + + var status operationStatusResponse + if err := json.Unmarshal(respBody, &status); err != nil { + return fmt.Errorf("не удалось разобрать статус операции %s: %w", opUid, err) + } + + // Печатаем этапы по мере выполнения — ВСЕГДА, без гейтинга. + // Завершённые (DtFinish != nil): [OK ]/[FAIL] с duration. + // Текущий (DtFinish == nil): [..] один раз при появлении. + for _, stage := range status.InstanceOperation.Stages { + if printedStages[stage.InstanceOperationStageUid] { + continue + } + if stage.DtFinish != nil && *stage.DtFinish != "" { + // Завершённый этап + printedStages[stage.InstanceOperationStageUid] = true + if stage.InstanceOperationStageUid == lastPendingUID { + lastPendingUID = "" + } + status2 := "OK " + if !stage.IsSuccessful { + status2 = "FAIL" + } + fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status2, stage.Stage, stage.Duration) + } else if stage.InstanceOperationStageUid != lastPendingUID { + // Текущий этап — показываем один раз + lastPendingUID = stage.InstanceOperationStageUid + fmt.Fprintf(tty, " [..] %s\n", stage.Stage) + } + } + + // Критерий завершения — dtFinish (НЕ МЕНЯТЬ) + if status.InstanceOperation.DtFinish != nil && strings.TrimSpace(*status.InstanceOperation.DtFinish) != "" { + if status.InstanceOperation.IsSuccessful != nil && !*status.InstanceOperation.IsSuccessful { + if status.InstanceOperation.ErrorLog != nil && strings.TrimSpace(*status.InstanceOperation.ErrorLog) != "" { + return fmt.Errorf("операция %s завершилась с ошибкой: %s", opUid, *status.InstanceOperation.ErrorLog) + } + return fmt.Errorf("операция %s завершилась с ошибкой", opUid) + } + if status.InstanceOperation.Duration != nil { + fmt.Fprintf(tty, " [DONE] %.1f sec\n", *status.InstanceOperation.Duration) + } + return nil + } + } + } +} + +// waitForInstanceIdle waits until no operation is pending/in-progress for the instance. +func (c *UniversalClient) waitForInstanceIdle(ctx context.Context, instanceUid string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("ожидание операции отменено для экземпляра %s", instanceUid) + case <-ticker.C: + if time.Now().After(deadline) { + return fmt.Errorf("экземпляр %s не перешёл в состояние ожидания за установленный таймаут в %.0f секунд", instanceUid, timeout.Seconds()) + } + + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return fmt.Errorf("не удалось проверить состояние экземпляра %s: %w", instanceUid, err) + } + if !state.OperationIsPending && !state.OperationIsInProgress { + return nil + } + } + } +} diff --git a/provider/internal/core/params.go b/provider/internal/core/params.go new file mode 100644 index 0000000..6abb3c1 --- /dev/null +++ b/provider/internal/core/params.go @@ -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 +} diff --git a/provider/internal/core/refsvc.go b/provider/internal/core/refsvc.go new file mode 100644 index 0000000..7718a16 --- /dev/null +++ b/provider/internal/core/refsvc.go @@ -0,0 +1,121 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// ===== Резолв refSvc-параметров (по ID) ===== +// +// NOTE(resourceRealm): доступные resourceRealm для сервиса получают через +// GET /resourceRealms/available?svcId= (через index.cfm proxy). +// +// NOTE(refSvcId lookup): если параметр операции create ссылается на другой сервис, +// то в cfsParams будет проставлен refSvcId — значение должно быть UUID инстанса +// указанного сервиса. +// +// ВАЖНО (uuid-case, 2026-03): API облака возвращает UUID всегда в нижнем регистре. +// Для отправки в API нормализуем к lowercase, но в state сохраняем регистр +// пользователя (см. шаблон instance.go, блоки "Restore user-provided casing"). + +func (c *UniversalClient) resolveRefSvcParamValues(ctx context.Context, opParams []universalCfsParam, params map[int]string) (map[int]string, error) { + if len(params) == 0 || len(opParams) == 0 { + return params, nil + } + + refById := make(map[int]int) + for _, p := range opParams { + if p.RefSvcId != nil && *p.RefSvcId > 0 { + refById[p.SvcOperationCfsParamId] = *p.RefSvcId + } + } + if len(refById) == 0 { + // Даже без refSvcId — резолвим s3Uid внутри map-fixed (соглашение об именах). + return c.ResolveS3UidInAllMapFixed(params, opParams), nil + } + + resolved := make(map[int]string, len(params)) + for paramId, value := range params { + newValue := value + if refSvcId, ok := refById[paramId]; ok { + if strings.TrimSpace(value) == "" { + newValue = "" + } else if !isUUIDLike(value) { + uid, err := c.findInstanceUidByDisplayNameRefSvc(ctx, refSvcId, value) + if err != nil { + return nil, err + } + if uid == "" { + return nil, fmt.Errorf("не удалось разрешить параметр %d: не найден экземпляр для serviceId=%d displayName=%s", paramId, refSvcId, value) + } + newValue = uid + } else { + newValue = strings.ToLower(value) + } + } else if strings.TrimSpace(value) != "" { + // map-fixed: резолвим s3Uid-подобные uuid sub-params внутри JSON. + newValue = c.resolveS3UidInMapFixed(paramId, value, opParams) + } + resolved[paramId] = newValue + } + + return resolved, nil +} + +// ResolveS3UidInAllMapFixed проходит по всем параметрам и для map-fixed +// резолвит s3Uid-подобные значения в UUID. Используется когда нет ни одного +// параметра с refSvcId (ранний выход resolveRefSvcParamValues). +func (c *UniversalClient) ResolveS3UidInAllMapFixed(params map[int]string, opParams []universalCfsParam) map[int]string { + result := make(map[int]string, len(params)) + for paramId, value := range params { + if strings.TrimSpace(value) != "" && value != "{}" && strings.HasPrefix(strings.TrimSpace(value), "{") { + result[paramId] = c.resolveS3UidInMapFixed(paramId, value, opParams) + } else { + result[paramId] = value + } + } + return result +} + +// resolveS3UidInMapFixed парсит JSON map-fixed-параметра, находит ключи +// по паттерну s3.*uid (case-insensitive) и резолвит значения-не-UUID +// в UUID через S3 (сервис 12). Соглашение об именах: s3Uid, s3_uid, S3Uid → S3. +func (c *UniversalClient) resolveS3UidInMapFixed(paramId int, jsonValue string, opParams []universalCfsParam) string { + if jsonValue == "" || jsonValue == "{}" { + return jsonValue + } + + var obj map[string]interface{} + if err := json.Unmarshal([]byte(jsonValue), &obj); err != nil { + return jsonValue + } + + changed := false + for key, val := range obj { + lowKey := strings.ToLower(key) + if !strings.Contains(lowKey, "s3") || !strings.Contains(lowKey, "uid") { + continue + } + strVal, ok := val.(string) + if !ok || strVal == "" || isUUIDLike(strVal) { + continue + } + uid, err := c.findInstanceUidByDisplayNameRefSvc(context.Background(), 12, strVal) + if err != nil || uid == "" { + continue + } + obj[key] = uid + changed = true + } + + if !changed { + return jsonValue + } + b, err := json.Marshal(obj) + if err != nil { + return jsonValue + } + return string(b) +} diff --git a/provider/internal/core/refsvc_find.go b/provider/internal/core/refsvc_find.go new file mode 100644 index 0000000..c2c76fe --- /dev/null +++ b/provider/internal/core/refsvc_find.go @@ -0,0 +1,158 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// ===== Поиск инстансов для refSvc (по displayName/UUID, с учётом сервиса) ===== + +func (c *UniversalClient) findInstanceUidByDisplayNameRefSvc(ctx context.Context, serviceId int, displayName string) (string, error) { + // Собираем ВСЕ совпадения, затем выбираем лучшее (running > suspended > остальные). + // Deleted инстансы пропускаются — они не должны участвовать в resolve. + type candidate struct { + uid string + status string + } + var candidates []candidate + + page := 1 + for { + path := fmt.Sprintf("/instances?page=%d&size=100", page) + respBody, _, err := c.doRequest(ctx, "GET", path, nil) + if err != nil { + return "", err + } + + var res struct { + Results []struct { + InstanceUid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceId int `json:"serviceId"` + IsDeleted bool `json:"isDeleted"` + ExplainedStatus string `json:"explainedStatus"` + } `json:"results"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return "", err + } + + if len(res.Results) == 0 { + break + } + + for _, item := range res.Results { + if item.ServiceId != serviceId || !strings.EqualFold(item.DisplayName, displayName) { + continue + } + // Пропускаем deleted инстансы + if item.IsDeleted { + continue + } + status := strings.ToLower(strings.TrimSpace(item.ExplainedStatus)) + if status == "deleted" { + continue + } + candidates = append(candidates, candidate{uid: item.InstanceUid, status: status}) + } + + page++ + if page > 100 { + break + } + } + + if len(candidates) == 0 { + return "", nil + } + if len(candidates) == 1 { + return candidates[0].uid, nil + } + // Несколько non-deleted совпадений — предпочитаем running + for _, c := range candidates { + if strings.Contains(c.status, "running") || strings.Contains(c.status, "active") { + return c.uid, nil + } + } + // Нет running — предпочитаем suspended + for _, c := range candidates { + if strings.Contains(c.status, "suspend") { + return c.uid, nil + } + } + // Вернуть первый (лучше чем ничего) + return candidates[0].uid, nil +} + +// findInstanceDisplayNameByUidRefSvc resolves display_name by instance UID and service ID. +func (c *UniversalClient) findInstanceDisplayNameByUidRefSvc(ctx context.Context, serviceId int, instanceUid string) (string, error) { + page := 1 + for { + path := fmt.Sprintf("/instances?page=%d&size=100", page) + respBody, _, err := c.doRequest(ctx, "GET", path, nil) + if err != nil { + return "", err + } + + var res struct { + Results []struct { + InstanceUid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceId int `json:"serviceId"` + } `json:"results"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return "", err + } + + if len(res.Results) == 0 { + break + } + + for _, item := range res.Results { + if item.ServiceId == serviceId && strings.EqualFold(item.InstanceUid, instanceUid) { + return item.DisplayName, nil + } + } + + page++ + if page > 100 { + break + } + } + + return "", nil +} + +// getInstanceDisplayNameByUidRefSvc fetches display_name by instance UID and checks service ID. +func (c *UniversalClient) getInstanceDisplayNameByUidRefSvc(ctx context.Context, serviceId int, instanceUid string) (string, error) { + if strings.TrimSpace(instanceUid) == "" { + return "", nil + } + + respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) + if err != nil { + return "", err + } + + var res struct { + Instance struct { + InstanceUid string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceId int `json:"serviceId"` + } `json:"instance"` + } + if err := json.Unmarshal(respBody, &res); err != nil { + return "", err + } + if strings.TrimSpace(res.Instance.InstanceUid) == "" { + return "", nil + } + if serviceId > 0 && res.Instance.ServiceId != serviceId { + return "", nil + } + + return res.Instance.DisplayName, nil +} diff --git a/provider/internal/core/types.go b/provider/internal/core/types.go new file mode 100644 index 0000000..41b7117 --- /dev/null +++ b/provider/internal/core/types.go @@ -0,0 +1,104 @@ +package core + +// ===== DTO / API-контракты Nubes API ===== +// +// Здесь только структуры (без логики): +// - generic* — простые payload'ы создания инстанса/операции/параметра +// - universal* — ответы схемы операций (cfsParams) и параметры +// - InstanceStateResponse / ApiOperation — состояние инстанса +// - operationStatusResponse / opStage — статус операции при поллинге + +type genericInstanceReq struct { + ServiceId int `json:"serviceId"` + DisplayName string `json:"displayName"` + Descr string `json:"descr"` +} + +type genericOpReq struct { + InstanceUid string `json:"instanceUid"` + Operation string `json:"operation"` +} + +type genericParamReq struct { + InstanceOperationUid string `json:"instanceOperationUid"` + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue string `json:"paramValue"` +} + +// ===== UNIVERSAL FLOW V6 (baseline) ===== + +type universalOpResponse struct { + InstanceOperation universalOperation `json:"instanceOperation"` +} + +type universalOperation struct { + CfsParams []universalCfsParam `json:"cfsParams"` +} + +// universalOpDefaultResponse — схема операции из GET /instanceOperations/default/{opId}. +// Не вычисляет instance-специфичные выражения (getResourceRealmConfig) и не падает +// с 500 на проблемных инстансах (см. docs/DEBUG_REPORT_VC_VDC_500.md). +type universalOpDefaultResponse struct { + SvcOperation universalOperation `json:"svcOperation"` +} + +type universalSubParam struct { + DefaultValue string `json:"defaultValue"` + DataType string `json:"dataType"` + SvcOperationCfsSubparam string `json:"svcOperationCfsSubparam"` + IsRequired bool `json:"isRequired"` +} + +type universalCfsParam struct { + SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` + ParamValue *string `json:"paramValue"` + DefaultValue *string `json:"defaultValue"` + IsRequired bool `json:"isRequired"` + DataType string `json:"dataType"` + Name string `json:"name"` + Code string `json:"code"` + Label string `json:"label"` + SvcOperationCfsParam string `json:"svcOperationCfsParam"` + RefSvcId *int `json:"refSvcId"` + DataDescriptor map[string]universalSubParam `json:"dataDescriptor"` +} + +// ===== Instance state ===== + +type ApiOperation struct { + SvcOperationId int `json:"svcOperationId"` + Operation string `json:"operation"` +} + +type InstanceStateResponse struct { + InstanceUid string `json:"instanceUid"` + ServiceId int `json:"serviceId"` + ExplainedStatus string `json:"explainedStatus"` + IsDeleted bool `json:"isDeleted"` + OperationIsInProgress bool `json:"operationIsInProgress"` + OperationIsPending bool `json:"operationIsPending"` + AvailableOperations []ApiOperation `json:"availableOperations"` +} + +// ===== Operation status (polling) ===== + +type opStage struct { + InstanceOperationStageUid string `json:"instanceOperationStageUid"` + Stage string `json:"stage"` + IsSuccessful bool `json:"isSuccessful"` + DtFinish *string `json:"dtFinish"` + Duration float64 `json:"duration"` + StageMsg *string `json:"stageMsg"` +} + +type operationStatusResponse struct { + InstanceOperation struct { + DtFinish *string `json:"dtFinish"` + IsSuccessful *bool `json:"isSuccessful"` + ErrorLog *string `json:"errorLog"` + IsInProgress bool `json:"isInProgress"` + IsPending bool `json:"isPending"` + Duration *float64 `json:"duration"` + Stages []opStage `json:"stages"` + } `json:"instanceOperation"` +} diff --git a/provider/internal/core/uuid.go b/provider/internal/core/uuid.go new file mode 100644 index 0000000..19c3eb6 --- /dev/null +++ b/provider/internal/core/uuid.go @@ -0,0 +1,29 @@ +package core + +import "strings" + +// ===== UUID-утилиты ===== + +func isUUIDLike(value string) bool { + trimmed := strings.TrimSpace(value) + if len(trimmed) != 36 { + return false + } + for i, r := range trimmed { + switch i { + case 8, 13, 18, 23: + if r != '-' { + return false + } + default: + if !isHexDigit(r) { + return false + } + } + } + return true +} + +func isHexDigit(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') +}