- add PollInterval, StagesWriter, RetryBaseDelay to UniversalClient - 5 unit tests for waitForOperationFinish (mock API, 0.25s total) - Sonnet/DeepSeek/Opus comparison answers archived
1699 lines
58 KiB
Go
1699 lines
58 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
|
||
// PollInterval — интервал поллинга. 0 = default 5s. Для тестов.
|
||
PollInterval time.Duration
|
||
// StagesWriter — writer для вывода этапов. nil = ttyOut(). Для тестов.
|
||
StagesWriter io.Writer
|
||
// RetryBaseDelay — базовая задержка retry в doRequest. 0 = default 2s. Для тестов.
|
||
RetryBaseDelay time.Duration
|
||
}
|
||
|
||
// instanceMutexes — глобальная карта мьютексов для сериализации операций на одном инстансе.
|
||
// Terraform параллелит ресурсы (до 10), но API Nubes не поддерживает параллельные операции
|
||
// на одном инстансе (orchestrator error, state_out inconsistency).
|
||
var instanceMutexes sync.Map
|
||
|
||
// LockInstance блокирует мьютекс для указанного instanceUid.
|
||
// Возвращает функцию unlock, которую нужно вызывать через defer.
|
||
func (c *UniversalClient) LockInstance(instanceUid string) func() {
|
||
mu, _ := instanceMutexes.LoadOrStore(instanceUid, &sync.Mutex{})
|
||
mu.(*sync.Mutex).Lock()
|
||
return func() { mu.(*sync.Mutex).Unlock() }
|
||
}
|
||
|
||
// isProxyAPI returns true if ApiEndpoint uses legacy ?endpoint= proxy pattern (contains "index.cfm").
|
||
func (c *UniversalClient) isProxyAPI() bool {
|
||
return strings.Contains(c.ApiEndpoint, "index.cfm")
|
||
}
|
||
|
||
// buildURL constructs a full URL from the base endpoint + path.
|
||
// Legacy proxy: index.cfm?endpoint=/instances&page=1
|
||
// New REST gateway: /api/v1/svc/instances?page=1
|
||
func (c *UniversalClient) buildURL(path string) string {
|
||
if c.isProxyAPI() {
|
||
endpointPath := path
|
||
extraQuery := ""
|
||
if idx := strings.Index(path, "?"); idx >= 0 {
|
||
endpointPath = path[:idx]
|
||
extraQuery = path[idx+1:]
|
||
}
|
||
rawQuery := "endpoint=" + endpointPath
|
||
if extraQuery != "" {
|
||
rawQuery += "&" + extraQuery
|
||
}
|
||
return c.ApiEndpoint + "?" + rawQuery
|
||
}
|
||
return c.ApiEndpoint + path
|
||
}
|
||
|
||
// ctxKeyLogLevel — ключ для переопределения LogLevel на уровне ресурса через context.WithValue.
|
||
type ctxKeyLogLevelType struct{}
|
||
|
||
var ctxKeyLogLevel = ctxKeyLogLevelType{}
|
||
|
||
// userAgent — браузерный, чтобы пройти DDoS-Guard.
|
||
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
|
||
const userAgent = "Mozilla/5.0"
|
||
|
||
// CtxWithLogLevel возвращает ctx с переопределённым уровнем логирования.
|
||
func CtxWithLogLevel(ctx context.Context, level string) context.Context {
|
||
return context.WithValue(ctx, ctxKeyLogLevel, level)
|
||
}
|
||
|
||
// reTimestamp удаляет временны́е метки вида [2026-03-24T05:07:47.716Z +0.860s] из строк.
|
||
var reTimestamp = regexp.MustCompile(`\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[^\]]+\]\s*`)
|
||
|
||
// formatStageMsg парсит stageMsg (JSON-массив пар [имя, текст]) и возвращает
|
||
// отформатированные строки для вывода. Временны́е метки убираются.
|
||
func formatStageMsg(raw string) []string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
// Пытаемся распарсить как [["name","text"],...]
|
||
var pairs [][]string
|
||
if err := json.Unmarshal([]byte(raw), &pairs); err != nil || len(pairs) == 0 {
|
||
return nil
|
||
}
|
||
// Вычисляем максимальную ширину имени для выравнивания
|
||
maxLen := 0
|
||
for _, p := range pairs {
|
||
if len(p) >= 1 && len(p[0]) > maxLen {
|
||
maxLen = len(p[0])
|
||
}
|
||
}
|
||
var result []string
|
||
for _, p := range pairs {
|
||
if len(p) < 2 {
|
||
continue
|
||
}
|
||
name := p[0]
|
||
text := reTimestamp.ReplaceAllString(p[1], "")
|
||
lines := strings.Split(strings.TrimSpace(text), "\n")
|
||
padding := strings.Repeat(" ", maxLen-len(name))
|
||
for i, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
if i == 0 {
|
||
result = append(result, fmt.Sprintf(" %s:%s %s", name, padding, line))
|
||
} else {
|
||
result = append(result, fmt.Sprintf(" %s %s", strings.Repeat(" ", maxLen), line))
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
type genericInstanceReq struct {
|
||
ServiceId int `json:"serviceId"`
|
||
DisplayName string `json:"displayName"`
|
||
Descr string `json:"descr"`
|
||
}
|
||
|
||
type genericOpReq struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
Operation string `json:"operation"`
|
||
}
|
||
|
||
type genericParamReq struct {
|
||
InstanceOperationUid string `json:"instanceOperationUid"`
|
||
SvcOperationCfsParamId int `json:"svcOperationCfsParamId"`
|
||
ParamValue string `json:"paramValue"`
|
||
}
|
||
|
||
// ===== UNIVERSAL FLOW V6 (baseline) =====
|
||
|
||
type universalOpResponse struct {
|
||
InstanceOperation universalOperation `json:"instanceOperation"`
|
||
}
|
||
|
||
type universalOperation struct {
|
||
CfsParams []universalCfsParam `json:"cfsParams"`
|
||
}
|
||
|
||
type universalSubParam struct {
|
||
DefaultValue string `json:"defaultValue"`
|
||
DataType string `json:"dataType"`
|
||
SvcOperationCfsSubparam string `json:"svcOperationCfsSubparam"`
|
||
IsRequired bool `json:"isRequired"`
|
||
}
|
||
|
||
type universalCfsParam struct {
|
||
SvcOperationCfsParamId int `json:"svcOperationCfsParamId"`
|
||
ParamValue *string `json:"paramValue"`
|
||
DefaultValue *string `json:"defaultValue"`
|
||
IsRequired bool `json:"isRequired"`
|
||
DataType string `json:"dataType"`
|
||
Name string `json:"name"`
|
||
Code string `json:"code"`
|
||
Label string `json:"label"`
|
||
SvcOperationCfsParam string `json:"svcOperationCfsParam"`
|
||
RefSvcId *int `json:"refSvcId"`
|
||
DataDescriptor map[string]universalSubParam `json:"dataDescriptor"`
|
||
}
|
||
|
||
// CreateGenericInstanceUniversalV6 implements the universal flow:
|
||
// instances -> instanceOperations -> get cfsParams -> submit params -> validate -> run
|
||
func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) {
|
||
instPayload := genericInstanceReq{
|
||
ServiceId: serviceId,
|
||
DisplayName: displayName,
|
||
Descr: c.instanceDescr(),
|
||
}
|
||
|
||
instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
instanceUid := extractUIDFromLocation(instHeaders.Get("Location"))
|
||
if instanceUid == "" {
|
||
var instResult struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
}
|
||
if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" {
|
||
instanceUid = instResult.InstanceUid
|
||
} else {
|
||
var justId string
|
||
if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" {
|
||
instanceUid = justId
|
||
}
|
||
}
|
||
}
|
||
if instanceUid == "" {
|
||
return "", fmt.Errorf("не удалось извлечь instanceUid из ответа (Header: %s)", instHeaders.Get("Location"))
|
||
}
|
||
|
||
opPayload := genericOpReq{
|
||
InstanceUid: instanceUid,
|
||
Operation: "create",
|
||
}
|
||
|
||
opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
opUid := extractUIDFromLocation(opHeaders.Get("Location"))
|
||
if opUid == "" {
|
||
var opResult struct {
|
||
InstanceOperationUid string `json:"instanceOperationUid"`
|
||
}
|
||
if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" {
|
||
opUid = opResult.InstanceOperationUid
|
||
} else {
|
||
var justId string
|
||
if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" {
|
||
opUid = justId
|
||
}
|
||
}
|
||
}
|
||
if opUid == "" {
|
||
return "", fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location"))
|
||
}
|
||
|
||
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("не удалось получить детали операции: %w", err)
|
||
}
|
||
var opDetails universalOpResponse
|
||
if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil {
|
||
return "", fmt.Errorf("не удалось разобрать детали операции: %w", err)
|
||
}
|
||
|
||
params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
sent := make(map[int]bool)
|
||
for paramId, value := range params {
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: paramId,
|
||
ParamValue: value,
|
||
}
|
||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||
if err != nil {
|
||
return "", fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||
}
|
||
sent[paramId] = true
|
||
}
|
||
|
||
for _, param := range opDetails.InstanceOperation.CfsParams {
|
||
if sent[param.SvcOperationCfsParamId] {
|
||
continue
|
||
}
|
||
if !param.IsRequired {
|
||
continue
|
||
}
|
||
|
||
val := ""
|
||
if param.ParamValue != nil {
|
||
val = *param.ParamValue
|
||
} else if param.DefaultValue != nil {
|
||
val = *param.DefaultValue
|
||
}
|
||
val = normalizeUniversalValueV6(val, param)
|
||
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
|
||
ParamValue: val,
|
||
}
|
||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||
if err != nil {
|
||
return "", fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
|
||
}
|
||
}
|
||
|
||
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("валидация не пройдена: %w", err)
|
||
}
|
||
|
||
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||
if err != nil {
|
||
return "", fmt.Errorf("выполнение не удалось: %w", err)
|
||
}
|
||
|
||
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||
if err := c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, serviceId, "create")); err != nil {
|
||
return "", err
|
||
}
|
||
if err := c.ensureInstanceCreated(ctx, instanceUid); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return instanceUid, nil
|
||
}
|
||
|
||
// buildMapFixedDefault строит JSON-объект из дефолтов sub-параметров map-fixed.
|
||
func buildMapFixedDefault(param universalCfsParam) string {
|
||
result := make(map[string]string, len(param.DataDescriptor))
|
||
for key, sub := range param.DataDescriptor {
|
||
result[key] = sub.DefaultValue
|
||
}
|
||
b, err := json.Marshal(result)
|
||
if err != nil {
|
||
return "{}"
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func normalizeUniversalValueV6(val string, param universalCfsParam) string {
|
||
trimmed := strings.TrimSpace(val)
|
||
if strings.EqualFold(trimmed, "null") {
|
||
trimmed = ""
|
||
}
|
||
if trimmed == "\"\"" {
|
||
trimmed = ""
|
||
}
|
||
|
||
// map-fixed с DataDescriptor: если значение пустое или "{}" — строим JSON из дефолтов sub-params.
|
||
dataType := strings.ToLower(param.DataType)
|
||
if (dataType == "map-fixed" || strings.HasPrefix(dataType, "map")) && len(param.DataDescriptor) > 0 {
|
||
if trimmed == "" || trimmed == "{}" {
|
||
return buildMapFixedDefault(param)
|
||
}
|
||
return trimmed
|
||
}
|
||
|
||
if trimmed != "" {
|
||
return trimmed
|
||
}
|
||
|
||
nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam)
|
||
|
||
if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") {
|
||
return "[]"
|
||
}
|
||
if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") {
|
||
return "{}"
|
||
}
|
||
if strings.Contains(dataType, "integer") || strings.Contains(dataType, "int") {
|
||
return "0"
|
||
}
|
||
if strings.Contains(dataType, "boolean") || strings.Contains(dataType, "bool") {
|
||
return "false"
|
||
}
|
||
|
||
return trimmed
|
||
}
|
||
|
||
// RunInstanceOperationUniversal runs an available operation (modify/suspend/delete/resume) if possible.
|
||
func (c *UniversalClient) RunInstanceOperationUniversal(ctx context.Context, instanceUid string, action string, params map[int]string) error {
|
||
state, err := c.GetInstanceState(ctx, instanceUid)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if state.OperationIsPending || state.OperationIsInProgress {
|
||
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
var opId int
|
||
for _, op := range state.AvailableOperations {
|
||
if strings.EqualFold(op.Operation, action) {
|
||
opId = op.SvcOperationId
|
||
break
|
||
}
|
||
}
|
||
if opId == 0 {
|
||
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
||
}
|
||
|
||
payload := map[string]interface{}{
|
||
"instanceUid": instanceUid,
|
||
"svcOperationId": opId,
|
||
"operation": action,
|
||
}
|
||
|
||
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
|
||
}
|
||
if opUid == "" {
|
||
return fmt.Errorf("не удалось получить UID операции для %s", action)
|
||
}
|
||
|
||
for paramId, value := range params {
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: paramId,
|
||
ParamValue: value,
|
||
}
|
||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||
}
|
||
}
|
||
|
||
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
|
||
}
|
||
|
||
// RunInstanceOperationUniversalWithDefaults runs an operation and submits required params (including defaults).
|
||
func (c *UniversalClient) RunInstanceOperationUniversalWithDefaults(ctx context.Context, instanceUid string, action string, params map[int]string) error {
|
||
state, err := c.GetInstanceState(ctx, instanceUid)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if state.OperationIsPending || state.OperationIsInProgress {
|
||
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
var opId int
|
||
for _, op := range state.AvailableOperations {
|
||
if strings.EqualFold(op.Operation, action) {
|
||
opId = op.SvcOperationId
|
||
break
|
||
}
|
||
}
|
||
if opId == 0 {
|
||
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
||
}
|
||
|
||
payload := map[string]interface{}{
|
||
"instanceUid": instanceUid,
|
||
"svcOperationId": opId,
|
||
"operation": action,
|
||
}
|
||
|
||
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
|
||
}
|
||
if opUid == "" {
|
||
return fmt.Errorf("не удалось получить UID операции для %s", action)
|
||
}
|
||
|
||
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось получить детали операции: %w", err)
|
||
}
|
||
var opDetails universalOpResponse
|
||
if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil {
|
||
return fmt.Errorf("не удалось разобрать детали операции: %w", err)
|
||
}
|
||
|
||
params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
sent := make(map[int]bool)
|
||
for paramId, value := range params {
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: paramId,
|
||
ParamValue: value,
|
||
}
|
||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||
}
|
||
sent[paramId] = true
|
||
}
|
||
|
||
for _, param := range opDetails.InstanceOperation.CfsParams {
|
||
if sent[param.SvcOperationCfsParamId] {
|
||
continue
|
||
}
|
||
|
||
val := ""
|
||
if param.ParamValue != nil {
|
||
val = *param.ParamValue
|
||
} else if param.DefaultValue != nil {
|
||
val = *param.DefaultValue
|
||
}
|
||
val = normalizeUniversalValueV6(val, param)
|
||
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
|
||
ParamValue: val,
|
||
}
|
||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
|
||
}
|
||
}
|
||
|
||
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("валидация не пройдена: %w", err)
|
||
}
|
||
|
||
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
|
||
}
|
||
|
||
// RunRedeployOperation запускает redeploy для сервисов, поддерживающих пересборку из git.
|
||
// Если params не nil — отправляет CFS-параметры перед запуском.
|
||
func (c *UniversalClient) RunRedeployOperation(ctx context.Context, instanceUid string, timeoutOverride string, params map[int]string) error {
|
||
state, err := c.GetInstanceState(ctx, instanceUid)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if state.OperationIsPending || state.OperationIsInProgress {
|
||
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
opId := 0
|
||
for _, op := range state.AvailableOperations {
|
||
if strings.EqualFold(op.Operation, "redeploy") {
|
||
opId = op.SvcOperationId
|
||
break
|
||
}
|
||
}
|
||
if opId == 0 {
|
||
return fmt.Errorf("операция redeploy недоступна для экземпляра %s", instanceUid)
|
||
}
|
||
|
||
payload := map[string]interface{}{
|
||
"instanceUid": instanceUid,
|
||
"svcOperationId": opId,
|
||
"operation": "redeploy",
|
||
}
|
||
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось создать операцию redeploy: %w", err)
|
||
}
|
||
if opUid == "" {
|
||
return fmt.Errorf("не удалось получить UID операции redeploy")
|
||
}
|
||
|
||
for paramId, value := range params {
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: paramId,
|
||
ParamValue: value,
|
||
}
|
||
if _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload); err != nil {
|
||
return fmt.Errorf("не удалось установить параметр %d для redeploy: %w", paramId, err)
|
||
}
|
||
}
|
||
|
||
if _, _, err := c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}); err != nil {
|
||
return err
|
||
}
|
||
|
||
timeout := c.operationTimeoutForContext(ctx, state.ServiceId, "redeploy")
|
||
if timeoutOverride != "" {
|
||
if d, parseErr := time.ParseDuration(timeoutOverride); parseErr == nil {
|
||
timeout = d
|
||
}
|
||
}
|
||
return c.waitForOperationFinish(ctx, opUid, timeout)
|
||
}
|
||
|
||
// Instance state structures
|
||
|
||
type ApiOperation struct {
|
||
SvcOperationId int `json:"svcOperationId"`
|
||
Operation string `json:"operation"`
|
||
}
|
||
|
||
type InstanceStateResponse struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
ServiceId int `json:"serviceId"`
|
||
ExplainedStatus string `json:"explainedStatus"`
|
||
IsDeleted bool `json:"isDeleted"`
|
||
OperationIsInProgress bool `json:"operationIsInProgress"`
|
||
OperationIsPending bool `json:"operationIsPending"`
|
||
AvailableOperations []ApiOperation `json:"availableOperations"`
|
||
}
|
||
|
||
// FindInstanceByDisplayName finds an instance by display_name for a given serviceId.
|
||
// Если найдено больше одного non-deleted инстанса — возвращает ошибку.
|
||
func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, serviceId int, displayName string) (*InstanceStateResponse, error) {
|
||
debug := func(format string, args ...interface{}) {
|
||
f, err := os.OpenFile("/tmp/nubes_find_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer f.Close()
|
||
fmt.Fprintf(f, format+"\n", args...)
|
||
}
|
||
var found []InstanceStateResponse
|
||
|
||
// Быстрый поиск через search API
|
||
searchQuery := url.Values{}
|
||
searchQuery.Set("fields", "instanceUid,displayName,serviceId")
|
||
searchQuery.Set("page", "1")
|
||
searchQuery.Set("pageSize", "100")
|
||
searchQuery.Set("search", displayName)
|
||
searchQuery.Set("isAuxiliary", "false")
|
||
searchQuery.Set("isDeleted", "false")
|
||
if serviceId > 0 {
|
||
searchQuery.Set("serviceId", fmt.Sprintf("%d", serviceId))
|
||
}
|
||
respBody, _, err := c.doRequest(ctx, "GET", "/instances?"+searchQuery.Encode(), nil)
|
||
if err != nil {
|
||
debug("[FIND-DEBUG] search doRequest err: %v (serviceId=%d, name=%q)", err, serviceId, displayName)
|
||
} else {
|
||
var res struct {
|
||
Results []struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
DisplayName string `json:"displayName"`
|
||
ServiceId int `json:"serviceId"`
|
||
} `json:"results"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||
debug("[FIND-DEBUG] search json.Unmarshal err: %v (serviceId=%d, name=%q)", err, serviceId, displayName)
|
||
} else {
|
||
debug("[FIND-DEBUG] search returned %d results (serviceId=%d, name=%q)", len(res.Results), serviceId, displayName)
|
||
for _, item := range res.Results {
|
||
matchSvc := item.ServiceId == serviceId
|
||
matchName := strings.EqualFold(item.DisplayName, displayName)
|
||
debug("[FIND-DEBUG] search item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v",
|
||
item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName)
|
||
if matchSvc && matchName {
|
||
state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid)
|
||
if err != nil {
|
||
debug("[FIND-DEBUG] GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err)
|
||
continue
|
||
}
|
||
if state == nil {
|
||
debug("[FIND-DEBUG] GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid)
|
||
continue
|
||
}
|
||
if isInstanceDeleted(state) {
|
||
debug("[FIND-DEBUG] GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus)
|
||
continue
|
||
}
|
||
found = append(found, *state)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если быстрый поиск не дал результатов — пагинированный fallback
|
||
if len(found) == 0 {
|
||
debug("[FIND-DEBUG] search path failed, entering fallback (serviceId=%d, name=%q)", serviceId, displayName)
|
||
page := 1
|
||
for {
|
||
reqURL := c.buildURL(fmt.Sprintf("/instances?page=%d&size=100", page))
|
||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("User-Agent", userAgent)
|
||
if c.ApiToken != "" {
|
||
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||
}
|
||
|
||
resp, err := c.HttpClient.Do(req)
|
||
if err != nil {
|
||
debug("[FIND-DEBUG] fallback page=%d HttpClient.Do err: %v", page, err)
|
||
return nil, err
|
||
}
|
||
if resp.Body != nil {
|
||
defer resp.Body.Close()
|
||
}
|
||
|
||
if resp.StatusCode != 200 {
|
||
debug("[FIND-DEBUG] fallback page=%d HTTP %d", page, resp.StatusCode)
|
||
return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode)
|
||
}
|
||
|
||
var res struct {
|
||
Results []struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
DisplayName string `json:"displayName"`
|
||
ServiceId int `json:"serviceId"`
|
||
} `json:"results"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||
debug("[FIND-DEBUG] fallback page=%d json.Decode err: %v", page, err)
|
||
return nil, err
|
||
}
|
||
|
||
if len(res.Results) == 0 {
|
||
break
|
||
}
|
||
|
||
debug("[FIND-DEBUG] fallback page=%d has %d results", page, len(res.Results))
|
||
for _, item := range res.Results {
|
||
matchSvc := item.ServiceId == serviceId
|
||
matchName := strings.EqualFold(item.DisplayName, displayName)
|
||
if matchSvc || matchName {
|
||
debug("[FIND-DEBUG] fallback item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v",
|
||
item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName)
|
||
}
|
||
if matchSvc && matchName {
|
||
state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid)
|
||
if err != nil {
|
||
debug("[FIND-DEBUG] fallback GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err)
|
||
continue
|
||
}
|
||
if state == nil {
|
||
debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid)
|
||
continue
|
||
}
|
||
if isInstanceDeleted(state) {
|
||
debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus)
|
||
continue
|
||
}
|
||
found = append(found, *state)
|
||
}
|
||
}
|
||
|
||
page++
|
||
if page > 100 {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(found) == 0 {
|
||
return nil, nil
|
||
}
|
||
if len(found) == 1 {
|
||
return &found[0], nil
|
||
}
|
||
|
||
// Множественные non-deleted инстансы — ошибка
|
||
details := make([]string, 0, len(found))
|
||
for _, inst := range found {
|
||
details = append(details, fmt.Sprintf(" - %s (статус: %s)", inst.InstanceUid, inst.ExplainedStatus))
|
||
}
|
||
return nil, fmt.Errorf(
|
||
"обнаружено %d инстансов с именем '%s' (serviceId=%d):\n%s\nНевозможно определить какой adopt-ить. Удалите лишние через ЛК (Управление облаком) или укажите конкретный UUID через 'terraform import'",
|
||
len(found), displayName, serviceId, strings.Join(details, "\n"),
|
||
)
|
||
}
|
||
|
||
func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) {
|
||
const maxRetries = 3
|
||
baseDelay := 2 * time.Second
|
||
|
||
var lastErr error
|
||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||
if attempt > 0 {
|
||
select {
|
||
case <-time.After(baseDelay * time.Duration(1<<(attempt-1))):
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
|
||
url := c.buildURL(fmt.Sprintf("/instances/%s", instanceUid))
|
||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("User-Agent", userAgent)
|
||
if c.ApiToken != "" {
|
||
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||
}
|
||
|
||
resp, err := c.HttpClient.Do(req)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 401 {
|
||
lastErr = fmt.Errorf("HTTP статус %d", resp.StatusCode)
|
||
continue
|
||
}
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode)
|
||
}
|
||
|
||
var res struct {
|
||
Instance InstanceStateResponse `json:"instance"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := validateInstanceStatus(&res.Instance); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &res.Instance, nil
|
||
}
|
||
return nil, fmt.Errorf("GetInstanceState failed after %d retries: %w", maxRetries, lastErr)
|
||
}
|
||
|
||
func isInstanceDeleted(state *InstanceStateResponse) bool {
|
||
if state == nil {
|
||
return false
|
||
}
|
||
if state.IsDeleted {
|
||
return true
|
||
}
|
||
return strings.EqualFold(strings.TrimSpace(state.ExplainedStatus), "deleted")
|
||
}
|
||
|
||
// GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса.
|
||
// Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы.
|
||
func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) {
|
||
const maxRetries = 3
|
||
baseDelay := 2 * time.Second
|
||
|
||
var lastErr error
|
||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||
if attempt > 0 {
|
||
select {
|
||
case <-time.After(baseDelay * time.Duration(1<<(attempt-1))):
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
|
||
reqURL := c.buildURL(fmt.Sprintf("/instances/%s", instanceUid))
|
||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("User-Agent", userAgent)
|
||
if c.ApiToken != "" {
|
||
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||
}
|
||
|
||
resp, err := c.HttpClient.Do(req)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 401 {
|
||
lastErr = fmt.Errorf("HTTP статус %d", resp.StatusCode)
|
||
continue
|
||
}
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode)
|
||
}
|
||
|
||
var res struct {
|
||
Instance InstanceStateResponse `json:"instance"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &res.Instance, nil
|
||
}
|
||
return nil, fmt.Errorf("GetInstanceStateRaw failed after %d retries: %w", maxRetries, lastErr)
|
||
}
|
||
|
||
// ===== UNIVERSAL OPERATION WAIT (APPEND-ONLY) =====
|
||
//
|
||
// КРИТИЧЕСКИ ВАЖНО:
|
||
// 1. Критерий завершения операции — наличие dtFinish.
|
||
// 2. Эта логика едина для всех сервисов/операций и является контрактом поведения.
|
||
// 3. ЗАПРЕЩЕНО ПРАВИТЬ ЭТОТ КОД БЕЗ ЯВНОГО СОГЛАСОВАНИЯ С ОПЕРАТОРОМ.
|
||
// Любые изменения (таймауты, критерии завершения, частота опроса, обработка ошибок)
|
||
// должны быть согласованы заранее.
|
||
const defaultOperationTimeout = 30 * time.Minute
|
||
|
||
// ttyOut открывает /dev/tty для прямого вывода в терминал, минуя перехват terraform.
|
||
func ttyOut() *os.File {
|
||
if f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil {
|
||
return f
|
||
}
|
||
return os.Stderr
|
||
}
|
||
|
||
type opStage struct {
|
||
InstanceOperationStageUid string `json:"instanceOperationStageUid"`
|
||
Stage string `json:"stage"`
|
||
IsSuccessful bool `json:"isSuccessful"`
|
||
DtFinish *string `json:"dtFinish"`
|
||
Duration float64 `json:"duration"`
|
||
StageMsg *string `json:"stageMsg"`
|
||
}
|
||
|
||
type operationStatusResponse struct {
|
||
InstanceOperation struct {
|
||
DtFinish *string `json:"dtFinish"`
|
||
IsSuccessful *bool `json:"isSuccessful"`
|
||
ErrorLog *string `json:"errorLog"`
|
||
IsInProgress bool `json:"isInProgress"`
|
||
IsPending bool `json:"isPending"`
|
||
Duration *float64 `json:"duration"`
|
||
Stages []opStage `json:"stages"`
|
||
} `json:"instanceOperation"`
|
||
}
|
||
|
||
func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error {
|
||
deadline := time.Now().Add(timeout)
|
||
interval := c.PollInterval
|
||
if interval <= 0 {
|
||
interval = 5 * time.Second
|
||
}
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
|
||
printedStages := make(map[string]bool)
|
||
var lastPendingUID string
|
||
|
||
var tty io.Writer
|
||
if c.StagesWriter != nil {
|
||
tty = c.StagesWriter
|
||
} else {
|
||
f := ttyOut()
|
||
defer f.Close()
|
||
tty = f
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return fmt.Errorf("операция %s отменена", opUid)
|
||
case <-ticker.C:
|
||
if time.Now().After(deadline) {
|
||
return fmt.Errorf("операция %s не завершилась за установленный таймаут в %.0f секунд", opUid, timeout.Seconds())
|
||
}
|
||
|
||
respBody, _, err := c.doRequest(ctx, "GET",
|
||
fmt.Sprintf("/instanceOperations/%s?fields=dtFinish,isSuccessful,errorLog,isInProgress,isPending,duration,stages", opUid), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось проверить статус операции %s: %w", opUid, err)
|
||
}
|
||
|
||
var status operationStatusResponse
|
||
if err := json.Unmarshal(respBody, &status); err != nil {
|
||
return fmt.Errorf("не удалось разобрать статус операции %s: %w", opUid, err)
|
||
}
|
||
|
||
// Печатаем этапы по мере выполнения — ВСЕГДА, без гейтинга.
|
||
// Завершённые (DtFinish != nil): [OK ]/[FAIL] с duration.
|
||
// Текущий (DtFinish == nil): [..] один раз при появлении.
|
||
for _, stage := range status.InstanceOperation.Stages {
|
||
if printedStages[stage.InstanceOperationStageUid] {
|
||
continue
|
||
}
|
||
if stage.DtFinish != nil && *stage.DtFinish != "" {
|
||
// Завершённый этап
|
||
printedStages[stage.InstanceOperationStageUid] = true
|
||
if stage.InstanceOperationStageUid == lastPendingUID {
|
||
lastPendingUID = ""
|
||
}
|
||
status2 := "OK "
|
||
if !stage.IsSuccessful {
|
||
status2 = "FAIL"
|
||
}
|
||
fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status2, stage.Stage, stage.Duration)
|
||
} else if stage.InstanceOperationStageUid != lastPendingUID {
|
||
// Текущий этап — показываем один раз
|
||
lastPendingUID = stage.InstanceOperationStageUid
|
||
fmt.Fprintf(tty, " [..] %s\n", stage.Stage)
|
||
}
|
||
}
|
||
|
||
// Критерий завершения — dtFinish (НЕ МЕНЯТЬ)
|
||
if status.InstanceOperation.DtFinish != nil && strings.TrimSpace(*status.InstanceOperation.DtFinish) != "" {
|
||
if status.InstanceOperation.IsSuccessful != nil && !*status.InstanceOperation.IsSuccessful {
|
||
if status.InstanceOperation.ErrorLog != nil && strings.TrimSpace(*status.InstanceOperation.ErrorLog) != "" {
|
||
return fmt.Errorf("операция %s завершилась с ошибкой: %s", opUid, *status.InstanceOperation.ErrorLog)
|
||
}
|
||
return fmt.Errorf("операция %s завершилась с ошибкой", opUid)
|
||
}
|
||
if status.InstanceOperation.Duration != nil {
|
||
fmt.Fprintf(tty, " [DONE] %.1f sec\n", *status.InstanceOperation.Duration)
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// waitForInstanceIdle waits until no operation is pending/in-progress for the instance.
|
||
func (c *UniversalClient) waitForInstanceIdle(ctx context.Context, instanceUid string, timeout time.Duration) error {
|
||
deadline := time.Now().Add(timeout)
|
||
ticker := time.NewTicker(5 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return fmt.Errorf("ожидание операции отменено для экземпляра %s", instanceUid)
|
||
case <-ticker.C:
|
||
if time.Now().After(deadline) {
|
||
return fmt.Errorf("экземпляр %s не перешёл в состояние ожидания за установленный таймаут в %.0f секунд", instanceUid, timeout.Seconds())
|
||
}
|
||
|
||
state, err := c.GetInstanceState(ctx, instanceUid)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось проверить состояние экземпляра %s: %w", instanceUid, err)
|
||
}
|
||
if !state.OperationIsPending && !state.OperationIsInProgress {
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Internal HTTP helpers
|
||
|
||
func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
|
||
// User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md).
|
||
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
|
||
const maxRetries = 3
|
||
baseDelay := c.RetryBaseDelay
|
||
if baseDelay <= 0 {
|
||
baseDelay = 2 * time.Second
|
||
}
|
||
|
||
var lastErr error
|
||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||
if attempt > 0 {
|
||
delay := baseDelay * time.Duration(1<<(attempt-1)) // 2s, 4s, 8s
|
||
select {
|
||
case <-time.After(delay):
|
||
case <-ctx.Done():
|
||
return nil, nil, ctx.Err()
|
||
}
|
||
}
|
||
|
||
var body io.Reader
|
||
if payload != nil {
|
||
b, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
body = bytes.NewBuffer(b)
|
||
}
|
||
|
||
reqURL := c.buildURL(path)
|
||
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
// Форсируем новое TCP-соединение для POST/PUT/PATCH: DDoS-Guard может блочить их на keep-alive.
|
||
// GET-запросы (поиск, чтение) оставляем на keep-alive — req.Close на них ломает DDoS-Guard.
|
||
if method != "GET" {
|
||
req.Close = true
|
||
}
|
||
|
||
req.Header.Set("User-Agent", userAgent)
|
||
req.Header.Set("Accept", "*/*")
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
if c.ApiToken != "" {
|
||
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||
}
|
||
|
||
// DEBUG
|
||
if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
|
||
dump, _ := httputil.DumpRequestOut(req, body != nil)
|
||
fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump)
|
||
}
|
||
|
||
// DEBUG в файл
|
||
if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
|
||
f, _ := os.OpenFile("/tmp/nubes_debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||
if f != nil {
|
||
fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String())
|
||
f.Close()
|
||
}
|
||
}
|
||
resp, err := c.HttpClient.Do(req)
|
||
if err != nil {
|
||
lastErr = err
|
||
// Сетевые ошибки — retry (кроме последней попытки)
|
||
if attempt < maxRetries {
|
||
continue
|
||
}
|
||
return nil, nil, err
|
||
}
|
||
|
||
respBody, err := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
if err != nil {
|
||
lastErr = err
|
||
if attempt < maxRetries {
|
||
continue
|
||
}
|
||
return nil, nil, err
|
||
}
|
||
|
||
// Retry только для transient-ошибок и только для GET
|
||
if resp.StatusCode >= 400 {
|
||
if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) {
|
||
lastErr = formatAPIError(resp.StatusCode, respBody)
|
||
continue
|
||
}
|
||
return nil, nil, formatAPIError(resp.StatusCode, respBody)
|
||
}
|
||
|
||
return respBody, resp.Header, nil
|
||
}
|
||
|
||
return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr)
|
||
}
|
||
|
||
// isRetryable returns true for transient HTTP errors that can be retried.
|
||
func isRetryable(statusCode int) bool {
|
||
return statusCode == http.StatusTooManyRequests || // 429
|
||
statusCode == http.StatusServiceUnavailable || // 503
|
||
statusCode == http.StatusBadGateway || // 502
|
||
statusCode == http.StatusGatewayTimeout || // 504
|
||
statusCode == http.StatusUnauthorized // 401 — Gateway иногда отбрасывает валидный JWT
|
||
}
|
||
|
||
func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) {
|
||
respBody, headers, err := c.doRequest(ctx, "POST", path, payload)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if returnLocation {
|
||
if loc := headers.Get("Location"); loc != "" {
|
||
return extractUIDFromLocation(loc), nil
|
||
}
|
||
}
|
||
|
||
var justId string
|
||
if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" {
|
||
return justId, nil
|
||
}
|
||
|
||
return "", nil
|
||
}
|
||
|
||
func extractUIDFromLocation(loc string) string {
|
||
if loc == "" {
|
||
return ""
|
||
}
|
||
return strings.TrimPrefix(loc, "./")
|
||
}
|
||
|
||
// NOTE(resourceRealm): доступные resourceRealm для сервиса получают через
|
||
// GET /resourceRealms/available?svcId=<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 {
|
||
// Даже без refSvcId — резолвим s3Uid внутри map-fixed (соглашение об именах).
|
||
return c.ResolveS3UidInAllMapFixed(params, opParams), nil
|
||
}
|
||
|
||
resolved := make(map[int]string, len(params))
|
||
for paramId, value := range params {
|
||
newValue := value
|
||
if refSvcId, ok := refById[paramId]; ok {
|
||
if strings.TrimSpace(value) == "" {
|
||
newValue = ""
|
||
} else if !isUUIDLike(value) {
|
||
uid, err := c.findInstanceUidByDisplayNameRefSvc(ctx, refSvcId, value)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if uid == "" {
|
||
return nil, fmt.Errorf("не удалось разрешить параметр %d: не найден экземпляр для serviceId=%d displayName=%s", paramId, refSvcId, value)
|
||
}
|
||
newValue = uid
|
||
} else {
|
||
newValue = strings.ToLower(value)
|
||
}
|
||
} else if strings.TrimSpace(value) != "" {
|
||
// map-fixed: резолвим s3Uid-подобные uuid sub-params внутри JSON.
|
||
newValue = c.resolveS3UidInMapFixed(paramId, value, opParams)
|
||
}
|
||
resolved[paramId] = newValue
|
||
}
|
||
|
||
return resolved, nil
|
||
}
|
||
|
||
// ResolveS3UidInAllMapFixed проходит по всем параметрам и для map-fixed
|
||
// резолвит s3Uid-подобные значения в UUID. Используется когда нет ни одного
|
||
// параметра с refSvcId (ранний выход resolveRefSvcParamValues).
|
||
func (c *UniversalClient) ResolveS3UidInAllMapFixed(params map[int]string, opParams []universalCfsParam) map[int]string {
|
||
result := make(map[int]string, len(params))
|
||
for paramId, value := range params {
|
||
if strings.TrimSpace(value) != "" && value != "{}" && strings.HasPrefix(strings.TrimSpace(value), "{") {
|
||
result[paramId] = c.resolveS3UidInMapFixed(paramId, value, opParams)
|
||
} else {
|
||
result[paramId] = value
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// resolveS3UidInMapFixed парсит JSON map-fixed-параметра, находит ключи
|
||
// по паттерну s3.*uid (case-insensitive) и резолвит значения-не-UUID
|
||
// в UUID через S3 (сервис 12). Соглашение об именах: s3Uid, s3_uid, S3Uid → S3.
|
||
func (c *UniversalClient) resolveS3UidInMapFixed(paramId int, jsonValue string, opParams []universalCfsParam) string {
|
||
if jsonValue == "" || jsonValue == "{}" {
|
||
return jsonValue
|
||
}
|
||
|
||
var obj map[string]interface{}
|
||
if err := json.Unmarshal([]byte(jsonValue), &obj); err != nil {
|
||
return jsonValue
|
||
}
|
||
|
||
changed := false
|
||
for key, val := range obj {
|
||
lowKey := strings.ToLower(key)
|
||
if !strings.Contains(lowKey, "s3") || !strings.Contains(lowKey, "uid") {
|
||
continue
|
||
}
|
||
strVal, ok := val.(string)
|
||
if !ok || strVal == "" || isUUIDLike(strVal) {
|
||
continue
|
||
}
|
||
uid, err := c.findInstanceUidByDisplayNameRefSvc(context.Background(), 12, strVal)
|
||
if err != nil || uid == "" {
|
||
continue
|
||
}
|
||
obj[key] = uid
|
||
changed = true
|
||
}
|
||
|
||
if !changed {
|
||
return jsonValue
|
||
}
|
||
b, err := json.Marshal(obj)
|
||
if err != nil {
|
||
return jsonValue
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func (c *UniversalClient) findInstanceUidByDisplayNameRefSvc(ctx context.Context, serviceId int, displayName string) (string, error) {
|
||
// Собираем ВСЕ совпадения, затем выбираем лучшее (running > suspended > остальные).
|
||
// Deleted инстансы пропускаются — они не должны участвовать в resolve.
|
||
type candidate struct {
|
||
uid string
|
||
status string
|
||
}
|
||
var candidates []candidate
|
||
|
||
page := 1
|
||
for {
|
||
path := fmt.Sprintf("/instances?page=%d&size=100", page)
|
||
respBody, _, err := c.doRequest(ctx, "GET", path, nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var res struct {
|
||
Results []struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
DisplayName string `json:"displayName"`
|
||
ServiceId int `json:"serviceId"`
|
||
IsDeleted bool `json:"isDeleted"`
|
||
ExplainedStatus string `json:"explainedStatus"`
|
||
} `json:"results"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if len(res.Results) == 0 {
|
||
break
|
||
}
|
||
|
||
for _, item := range res.Results {
|
||
if item.ServiceId != serviceId || !strings.EqualFold(item.DisplayName, displayName) {
|
||
continue
|
||
}
|
||
// Пропускаем deleted инстансы
|
||
if item.IsDeleted {
|
||
continue
|
||
}
|
||
status := strings.ToLower(strings.TrimSpace(item.ExplainedStatus))
|
||
if status == "deleted" {
|
||
continue
|
||
}
|
||
candidates = append(candidates, candidate{uid: item.InstanceUid, status: status})
|
||
}
|
||
|
||
page++
|
||
if page > 100 {
|
||
break
|
||
}
|
||
}
|
||
|
||
if len(candidates) == 0 {
|
||
return "", nil
|
||
}
|
||
if len(candidates) == 1 {
|
||
return candidates[0].uid, nil
|
||
}
|
||
// Несколько non-deleted совпадений — предпочитаем running
|
||
for _, c := range candidates {
|
||
if strings.Contains(c.status, "running") || strings.Contains(c.status, "active") {
|
||
return c.uid, nil
|
||
}
|
||
}
|
||
// Нет running — предпочитаем suspended
|
||
for _, c := range candidates {
|
||
if strings.Contains(c.status, "suspend") {
|
||
return c.uid, nil
|
||
}
|
||
}
|
||
// Вернуть первый (лучше чем ничего)
|
||
return candidates[0].uid, nil
|
||
}
|
||
|
||
func isUUIDLike(value string) bool {
|
||
trimmed := strings.TrimSpace(value)
|
||
if len(trimmed) != 36 {
|
||
return false
|
||
}
|
||
for i, r := range trimmed {
|
||
switch i {
|
||
case 8, 13, 18, 23:
|
||
if r != '-' {
|
||
return false
|
||
}
|
||
default:
|
||
if !isHexDigit(r) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func isHexDigit(r rune) bool {
|
||
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||
}
|
||
|
||
func (c *UniversalClient) ensureInstanceCreated(ctx context.Context, instanceUid string) error {
|
||
state, err := c.GetInstanceState(ctx, instanceUid)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось получить состояние экземпляра после создания: %w", err)
|
||
}
|
||
if state == nil {
|
||
return fmt.Errorf("отсутствует состояние экземпляра после создания для %s", instanceUid)
|
||
}
|
||
if isInstanceDeleted(state) {
|
||
return fmt.Errorf("экземпляр %s удален после создания", instanceUid)
|
||
}
|
||
status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus))
|
||
if status == "not created" {
|
||
return fmt.Errorf("экземпляр %s не создан после завершения операции. Видимо, такой инстанс уже существует в статусе Not Created. %s", instanceUid, formatInstanceStateDetails(state))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// findInstanceDisplayNameByUidRefSvc resolves display_name by instance UID and service ID.
|
||
func (c *UniversalClient) findInstanceDisplayNameByUidRefSvc(ctx context.Context, serviceId int, instanceUid string) (string, error) {
|
||
page := 1
|
||
for {
|
||
path := fmt.Sprintf("/instances?page=%d&size=100", page)
|
||
respBody, _, err := c.doRequest(ctx, "GET", path, nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var res struct {
|
||
Results []struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
DisplayName string `json:"displayName"`
|
||
ServiceId int `json:"serviceId"`
|
||
} `json:"results"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if len(res.Results) == 0 {
|
||
break
|
||
}
|
||
|
||
for _, item := range res.Results {
|
||
if item.ServiceId == serviceId && strings.EqualFold(item.InstanceUid, instanceUid) {
|
||
return item.DisplayName, nil
|
||
}
|
||
}
|
||
|
||
page++
|
||
if page > 100 {
|
||
break
|
||
}
|
||
}
|
||
|
||
return "", nil
|
||
}
|
||
|
||
// getInstanceDisplayNameByUidRefSvc fetches display_name by instance UID and checks service ID.
|
||
func (c *UniversalClient) getInstanceDisplayNameByUidRefSvc(ctx context.Context, serviceId int, instanceUid string) (string, error) {
|
||
if strings.TrimSpace(instanceUid) == "" {
|
||
return "", nil
|
||
}
|
||
|
||
respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var res struct {
|
||
Instance struct {
|
||
InstanceUid string `json:"instanceUid"`
|
||
DisplayName string `json:"displayName"`
|
||
ServiceId int `json:"serviceId"`
|
||
} `json:"instance"`
|
||
}
|
||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||
return "", err
|
||
}
|
||
if strings.TrimSpace(res.Instance.InstanceUid) == "" {
|
||
return "", nil
|
||
}
|
||
if serviceId > 0 && res.Instance.ServiceId != serviceId {
|
||
return "", nil
|
||
}
|
||
|
||
return res.Instance.DisplayName, nil
|
||
}
|
||
|
||
func validateInstanceStatus(state *InstanceStateResponse) error {
|
||
if state == nil {
|
||
return fmt.Errorf("отсутствует состояние экземпляра")
|
||
}
|
||
if isInstanceDeleted(state) {
|
||
return &instanceDeletedError{InstanceUID: state.InstanceUid}
|
||
}
|
||
if state.OperationIsPending || state.OperationIsInProgress {
|
||
return fmt.Errorf("экземпляр %s не готов: операция в ожидании", state.InstanceUid)
|
||
}
|
||
status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus))
|
||
if strings.Contains(status, "not created") {
|
||
return fmt.Errorf("экземпляр %s не создан. Видимо, такой инстанс уже существует в статусе Not Created. %s", state.InstanceUid, formatInstanceStateDetails(state))
|
||
}
|
||
if strings.Contains(status, "pending") {
|
||
return fmt.Errorf("экземпляр %s в ожидании. %s", state.InstanceUid, formatInstanceStateDetails(state))
|
||
}
|
||
if strings.Contains(status, "failed") || strings.Contains(status, "error") {
|
||
return fmt.Errorf("экземпляр %s завершился с ошибкой: %s. %s", state.InstanceUid, state.ExplainedStatus, formatInstanceStateDetails(state))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func formatInstanceStateDetails(state *InstanceStateResponse) string {
|
||
if state == nil {
|
||
return "details: state=nil"
|
||
}
|
||
statusRaw := strings.TrimSpace(state.ExplainedStatus)
|
||
statusNorm := strings.ToLower(statusRaw)
|
||
return "details: instance_uid=" + state.InstanceUid + ", status=\"" + statusNorm + "\", status_raw=\"" + statusRaw + "\", operation_pending=" + formatBool(state.OperationIsPending) + ", operation_in_progress=" + formatBool(state.OperationIsInProgress)
|
||
}
|
||
|
||
func formatBool(value bool) string {
|
||
if value {
|
||
return "true"
|
||
}
|
||
return "false"
|
||
}
|
||
|
||
// RunInstanceOperationUniversalByCode runs an operation using params keyed by code.
|
||
// It resolves param codes to IDs via operation manifest, applies defaults, validates, and runs.
|
||
func (c *UniversalClient) RunInstanceOperationUniversalByCode(ctx context.Context, instanceUid string, action string, params map[string]string) error {
|
||
state, err := c.GetInstanceState(ctx, instanceUid)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if state.OperationIsPending || state.OperationIsInProgress {
|
||
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
var opId int
|
||
for _, op := range state.AvailableOperations {
|
||
if strings.EqualFold(op.Operation, action) {
|
||
opId = op.SvcOperationId
|
||
break
|
||
}
|
||
}
|
||
if opId == 0 {
|
||
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
||
}
|
||
|
||
payload := map[string]interface{}{
|
||
"instanceUid": instanceUid,
|
||
"svcOperationId": opId,
|
||
"operation": action,
|
||
}
|
||
|
||
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
|
||
}
|
||
if opUid == "" {
|
||
return fmt.Errorf("не удалось получить UID операции для %s", action)
|
||
}
|
||
|
||
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось получить детали операции: %w", err)
|
||
}
|
||
var opDetails universalOpResponse
|
||
if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil {
|
||
return fmt.Errorf("не удалось разобрать детали операции: %w", err)
|
||
}
|
||
|
||
codeToParam := make(map[string]universalCfsParam)
|
||
for _, p := range opDetails.InstanceOperation.CfsParams {
|
||
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
|
||
codeToParam[key] = p
|
||
}
|
||
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
|
||
codeToParam[key] = p
|
||
}
|
||
}
|
||
|
||
paramsByID := map[int]string{}
|
||
for code, value := range params {
|
||
key := strings.ToLower(strings.TrimSpace(code))
|
||
p, ok := codeToParam[key]
|
||
if !ok {
|
||
return fmt.Errorf("код параметра %s не найден для операции %s", code, action)
|
||
}
|
||
paramsByID[p.SvcOperationCfsParamId] = value
|
||
}
|
||
|
||
paramsByID, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, paramsByID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
sent := make(map[int]bool)
|
||
for paramId, value := range paramsByID {
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: paramId,
|
||
ParamValue: value,
|
||
}
|
||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||
}
|
||
sent[paramId] = true
|
||
}
|
||
|
||
for _, param := range opDetails.InstanceOperation.CfsParams {
|
||
if sent[param.SvcOperationCfsParamId] {
|
||
continue
|
||
}
|
||
if !param.IsRequired {
|
||
continue
|
||
}
|
||
val := ""
|
||
if param.ParamValue != nil {
|
||
val = *param.ParamValue
|
||
} else if param.DefaultValue != nil {
|
||
val = *param.DefaultValue
|
||
}
|
||
val = normalizeUniversalValueV6(val, param)
|
||
if !param.IsRequired && strings.TrimSpace(val) == "" {
|
||
continue
|
||
}
|
||
|
||
pPayload := genericParamReq{
|
||
InstanceOperationUid: opUid,
|
||
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
|
||
ParamValue: val,
|
||
}
|
||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||
if err != nil {
|
||
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
|
||
}
|
||
}
|
||
|
||
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("валидация не пройдена: %w", err)
|
||
}
|
||
|
||
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
|
||
}
|
||
|
||
// instanceDescr returns a stable description with provider version when available.
|
||
func (c *UniversalClient) instanceDescr() string {
|
||
version := strings.TrimSpace(c.ProviderVersion)
|
||
if version == "" {
|
||
return "Создано через Nubes Terraform Universal Provider"
|
||
}
|
||
return fmt.Sprintf("Создано через Nubes Terraform Universal Provider %s", version)
|
||
}
|
||
|
||
// instanceDeletedError marks deleted instances to be treated as not found.
|
||
type instanceDeletedError struct {
|
||
InstanceUID string
|
||
}
|
||
|
||
func (e *instanceDeletedError) Error() string {
|
||
return fmt.Sprintf("экземпляр %s удален", e.InstanceUID)
|
||
}
|
||
|
||
func isInstanceDeletedError(err error) bool {
|
||
var e *instanceDeletedError
|
||
return errors.As(err, &e)
|
||
}
|
||
|
||
// formatAPIError парсит JSON-ответ API и возвращает читаемое сообщение.
|
||
// Если тело не является JSON с полем ERROR — возвращает сырой текст.
|
||
func formatAPIError(statusCode int, body []byte) error {
|
||
var parsed struct {
|
||
Error string `json:"ERROR"`
|
||
Detail string `json:"DETAIL"`
|
||
}
|
||
if json.Unmarshal(body, &parsed) == nil && parsed.Error != "" {
|
||
msg := parsed.Error
|
||
if d := strings.TrimSpace(parsed.Detail); d != "" {
|
||
msg += ": " + d
|
||
}
|
||
return fmt.Errorf("ошибка API %d: %s", statusCode, msg)
|
||
}
|
||
return fmt.Errorf("ошибка API %d: %s", statusCode, strings.TrimSpace(string(body)))
|
||
}
|