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

1582 lines
54 KiB
Go

// 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
}
// 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 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"`
}
// 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
}
func normalizeUniversalValueV6(val string, param universalCfsParam) string {
trimmed := strings.TrimSpace(val)
if strings.EqualFold(trimmed, "null") {
trimmed = ""
}
if trimmed == "\"\"" {
trimmed = ""
}
if trimmed != "" {
return trimmed
}
dataType := strings.ToLower(param.DataType)
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 "{}"
}
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.
// В отличие от modify, redeploy не требует CFS-параметров — операция запускается без них.
// Используется генератором (gen_v2) согласно ARCHITECTURE.md.
func (c *UniversalClient) RunRedeployOperation(ctx context.Context, instanceUid string, timeoutOverride 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")
}
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)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
printedStages := make(map[string]bool)
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)
}
// Определяем уровень логирования: ctx перекрывает c.LogLevel
logLevel := c.LogLevel
if v, ok := ctx.Value(ctxKeyLogLevel).(string); ok && v != "" {
logLevel = v
}
showStages := logLevel == "info" || logLevel == "debug"
showDetails := logLevel == "debug"
// Печатаем завершённые этапы по мере появления
tty := ttyOut()
defer tty.Close()
for _, stage := range status.InstanceOperation.Stages {
if printedStages[stage.InstanceOperationStageUid] {
continue
}
if stage.DtFinish == nil || *stage.DtFinish == "" {
continue
}
printedStages[stage.InstanceOperationStageUid] = true
if !showStages {
continue
}
status2 := "OK "
if !stage.IsSuccessful {
status2 = "FAIL"
}
fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status2, stage.Stage, stage.Duration)
if showDetails && stage.StageMsg != nil {
for _, line := range formatStageMsg(*stage.StageMsg) {
fmt.Fprintf(tty, "%s\n", line)
}
}
}
// Критерий завершения — 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 showStages && 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 := 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=<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/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 {
return params, 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) == "" {
// Опциональный ref-параметр не задан — оставляем пустым
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 {
// FIX(uuid-case): UUID-like value passed directly — normalise to lowercase.
// Аналогично refSvcId==12: нормализуем для API, регистр восстановится в state.
newValue = strings.ToLower(value)
}
}
resolved[paramId] = newValue
}
return resolved, nil
}
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)))
}