Files
tf_provider/provider/internal/core/client.go
T

1655 lines
57 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"path/filepath"
"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"
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"`
}
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{}) {
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=<serviceId> (через 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)
}
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)))
}