// Package core — ядро провайдера: HTTP-клиент Nubes API. // // Содержит: // - Client — HTTP-клиент с retry, таймаутами, Bearer-токеном // - Методы GetInstance*, RunOperation*, GetInstanceState* // - Логику определения proxy vs REST API (isProxyAPI) // // ⛔ НЕ ИЗМЕНЯТЬ НИЧЕГО В ЯДРЕ БЕЗ ПРЯМОГО РАЗРЕШЕНИЯ ОПЕРАТОРА. package core import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httputil" "net/url" "os" "regexp" "strings" "sync" "time" ) // UniversalClient handles Nubes API logic. type UniversalClient struct { HttpClient *http.Client ApiEndpoint string ApiToken string ProviderVersion string OperationTimeouts *OperationTimeouts // LogLevel задаёт уровень вывода этапов операции: ""/"none" | "info" | "debug". // Может быть переопределён на уровне ресурса через context (ключ ctxKeyLogLevel). LogLevel string // PollInterval — интервал поллинга. 0 = default 5s. Для тестов. PollInterval time.Duration // StagesWriter — writer для вывода этапов. nil = ttyOut(). Для тестов. StagesWriter io.Writer // RetryBaseDelay — базовая задержка retry в doRequest. 0 = default 2s. Для тестов. RetryBaseDelay time.Duration } // instanceMutexes — глобальная карта мьютексов для сериализации операций на одном инстансе. // Terraform параллелит ресурсы (до 10), но API Nubes не поддерживает параллельные операции // на одном инстансе (orchestrator error, state_out inconsistency). var instanceMutexes sync.Map // LockInstance блокирует мьютекс для указанного instanceUid. // Возвращает функцию unlock, которую нужно вызывать через defer. func (c *UniversalClient) LockInstance(instanceUid string) func() { mu, _ := instanceMutexes.LoadOrStore(instanceUid, &sync.Mutex{}) mu.(*sync.Mutex).Lock() return func() { mu.(*sync.Mutex).Unlock() } } // isProxyAPI returns true if ApiEndpoint uses legacy ?endpoint= proxy pattern (contains "index.cfm"). func (c *UniversalClient) isProxyAPI() bool { return strings.Contains(c.ApiEndpoint, "index.cfm") } // buildURL constructs a full URL from the base endpoint + path. // Legacy proxy: index.cfm?endpoint=/instances&page=1 // New REST gateway: /api/v1/svc/instances?page=1 func (c *UniversalClient) buildURL(path string) string { if c.isProxyAPI() { endpointPath := path extraQuery := "" if idx := strings.Index(path, "?"); idx >= 0 { endpointPath = path[:idx] extraQuery = path[idx+1:] } rawQuery := "endpoint=" + endpointPath if extraQuery != "" { rawQuery += "&" + extraQuery } return c.ApiEndpoint + "?" + rawQuery } 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" // 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"` } 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) if err != nil { return "", fmt.Errorf("не удалось получить детали операции: %w", err) } var opDetails universalOpResponse if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { return "", fmt.Errorf("не удалось разобрать детали операции: %w", err) } 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 } 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 } // 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) } opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) if err != nil { return fmt.Errorf("не удалось получить детали операции: %w", err) } var opDetails universalOpResponse if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { return fmt.Errorf("не удалось разобрать детали операции: %w", err) } 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 } for _, param := range opDetails.InstanceOperation.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{}) { f, err := os.OpenFile("/tmp/nubes_find_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 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) 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 > 100 { break } } } 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) { const maxRetries = 3 baseDelay := 2 * time.Second var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { select { case <-time.After(baseDelay * time.Duration(1<<(attempt-1))): case <-ctx.Done(): return nil, ctx.Err() } } url := c.buildURL(fmt.Sprintf("/instances/%s", instanceUid)) req, err := http.NewRequestWithContext(ctx, "GET", url, 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 { lastErr = err continue } defer resp.Body.Close() if resp.StatusCode == 401 { lastErr = fmt.Errorf("HTTP статус %d", resp.StatusCode) continue } if resp.StatusCode != 200 { return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode) } var res struct { Instance InstanceStateResponse `json:"instance"` } if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { return nil, err } if err := validateInstanceStatus(&res.Instance); err != nil { return nil, err } return &res.Instance, nil } return nil, fmt.Errorf("GetInstanceState failed after %d retries: %w", maxRetries, lastErr) } 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) { const maxRetries = 3 baseDelay := 2 * time.Second var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { select { case <-time.After(baseDelay * time.Duration(1<<(attempt-1))): case <-ctx.Done(): return nil, ctx.Err() } } reqURL := c.buildURL(fmt.Sprintf("/instances/%s", instanceUid)) 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 { lastErr = err continue } defer resp.Body.Close() if resp.StatusCode == 401 { lastErr = fmt.Errorf("HTTP статус %d", resp.StatusCode) continue } if resp.StatusCode != 200 { return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode) } var res struct { Instance InstanceStateResponse `json:"instance"` } if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { return nil, err } return &res.Instance, nil } return nil, fmt.Errorf("GetInstanceStateRaw failed after %d retries: %w", maxRetries, lastErr) } // ===== 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 os.Getenv("NUBES_DEBUG_HTTP") == "1" { dump, _ := httputil.DumpRequestOut(req, body != nil) fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump) } // DEBUG в файл if os.Getenv("NUBES_DEBUG_HTTP") == "1" { f, _ := os.OpenFile("/tmp/nubes_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 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 (кроме последней попытки) if attempt < maxRetries { 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 statusCode == http.StatusUnauthorized // 401 — Gateway иногда отбрасывает валидный JWT } 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/gen_v2/generate_resources_v2.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) } opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) if err != nil { return fmt.Errorf("не удалось получить детали операции: %w", err) } var opDetails universalOpResponse if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { return fmt.Errorf("не удалось разобрать детали операции: %w", err) } codeToParam := make(map[string]universalCfsParam) for _, p := range opDetails.InstanceOperation.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, opDetails.InstanceOperation.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 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) if !param.IsRequired && strings.TrimSpace(val) == "" { continue } 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) if version == "" { return "Создано через Nubes Terraform Universal Provider" } 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))) }