1582 lines
54 KiB
Go
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)))
|
|
}
|