refactor(core): разбить client.go (1678 строк) на 15 мелких модулей по зонам ответственности
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ===== Ошибки ядра =====
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ===== Internal HTTP helpers =====
|
||||
|
||||
func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
|
||||
// User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md).
|
||||
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
|
||||
const maxRetries = 3
|
||||
baseDelay := c.RetryBaseDelay
|
||||
if baseDelay <= 0 {
|
||||
baseDelay = 2 * time.Second
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := baseDelay * time.Duration(1<<(attempt-1)) // 2s, 4s, 8s
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
body = bytes.NewBuffer(b)
|
||||
}
|
||||
|
||||
reqURL := c.buildURL(path)
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Форсируем новое TCP-соединение для POST/PUT/PATCH: DDoS-Guard может блочить их на keep-alive.
|
||||
// GET-запросы (поиск, чтение) оставляем на keep-alive — req.Close на них ломает DDoS-Guard.
|
||||
if method != "GET" {
|
||||
req.Close = true
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.ApiToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||||
}
|
||||
|
||||
// DEBUG
|
||||
if isHTTPDebugEnabled() {
|
||||
reqForDump := req.Clone(req.Context())
|
||||
reqForDump.Header = sanitizeAuthHeader(req.Header)
|
||||
dump, _ := httputil.DumpRequestOut(reqForDump, body != nil)
|
||||
fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump)
|
||||
}
|
||||
|
||||
// DEBUG в файл
|
||||
if isHTTPDebugEnabled() {
|
||||
f, _ := os.OpenFile(debugLogPath("nubes_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if f != nil {
|
||||
fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String())
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Сетевые ошибки: retry только для идемпотентного GET.
|
||||
if attempt < maxRetries && method == "GET" {
|
||||
continue
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if attempt < maxRetries {
|
||||
continue
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Retry только для transient-ошибок и только для GET
|
||||
if resp.StatusCode >= 400 {
|
||||
if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) {
|
||||
lastErr = formatAPIError(resp.StatusCode, respBody)
|
||||
continue
|
||||
}
|
||||
return nil, nil, formatAPIError(resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
return respBody, resp.Header, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// isRetryable returns true for transient HTTP errors that can be retried.
|
||||
func isRetryable(statusCode int) bool {
|
||||
return statusCode == http.StatusTooManyRequests || // 429
|
||||
statusCode == http.StatusServiceUnavailable || // 503
|
||||
statusCode == http.StatusBadGateway || // 502
|
||||
statusCode == http.StatusGatewayTimeout // 504
|
||||
}
|
||||
|
||||
func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) {
|
||||
respBody, headers, err := c.doRequest(ctx, "POST", path, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if returnLocation {
|
||||
if loc := headers.Get("Location"); loc != "" {
|
||||
return extractUIDFromLocation(loc), nil
|
||||
}
|
||||
}
|
||||
|
||||
var justId string
|
||||
if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" {
|
||||
return justId, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func extractUIDFromLocation(loc string) string {
|
||||
if loc == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(loc, "./")
|
||||
}
|
||||
|
||||
// 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)))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 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)
|
||||
var opDetails universalOpResponse
|
||||
hasOpDetails := false
|
||||
if err == nil {
|
||||
if jsonErr := json.Unmarshal(opDetailsResp, &opDetails); jsonErr != nil {
|
||||
return "", fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr)
|
||||
}
|
||||
hasOpDetails = true
|
||||
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
|
||||
}
|
||||
|
||||
if hasOpDetails {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindInstanceByDisplayName finds an instance by display_name for a given serviceId.
|
||||
// Если найдено больше одного non-deleted инстанса — возвращает ошибку.
|
||||
func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, serviceId int, displayName string) (*InstanceStateResponse, error) {
|
||||
debug := func(format string, args ...interface{}) {
|
||||
if !isHTTPDebugEnabled() {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(debugLogPath("nubes_find_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintf(f, format+"\n", args...)
|
||||
}
|
||||
var found []InstanceStateResponse
|
||||
|
||||
// Быстрый поиск через search API
|
||||
searchQuery := url.Values{}
|
||||
searchQuery.Set("fields", "instanceUid,displayName,serviceId")
|
||||
searchQuery.Set("page", "1")
|
||||
searchQuery.Set("pageSize", "100")
|
||||
searchQuery.Set("search", displayName)
|
||||
searchQuery.Set("isAuxiliary", "false")
|
||||
searchQuery.Set("isDeleted", "false")
|
||||
if serviceId > 0 {
|
||||
searchQuery.Set("serviceId", fmt.Sprintf("%d", serviceId))
|
||||
}
|
||||
respBody, _, err := c.doRequest(ctx, "GET", "/instances?"+searchQuery.Encode(), nil)
|
||||
if err != nil {
|
||||
debug("[FIND-DEBUG] search doRequest err: %v (serviceId=%d, name=%q)", err, serviceId, displayName)
|
||||
} else {
|
||||
var res struct {
|
||||
Results []struct {
|
||||
InstanceUid string `json:"instanceUid"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ServiceId int `json:"serviceId"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||||
debug("[FIND-DEBUG] search json.Unmarshal err: %v (serviceId=%d, name=%q)", err, serviceId, displayName)
|
||||
} else {
|
||||
debug("[FIND-DEBUG] search returned %d results (serviceId=%d, name=%q)", len(res.Results), serviceId, displayName)
|
||||
for _, item := range res.Results {
|
||||
matchSvc := item.ServiceId == serviceId
|
||||
matchName := strings.EqualFold(item.DisplayName, displayName)
|
||||
debug("[FIND-DEBUG] search item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v",
|
||||
item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName)
|
||||
if matchSvc && matchName {
|
||||
state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid)
|
||||
if err != nil {
|
||||
debug("[FIND-DEBUG] GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err)
|
||||
continue
|
||||
}
|
||||
if state == nil {
|
||||
debug("[FIND-DEBUG] GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid)
|
||||
continue
|
||||
}
|
||||
if isInstanceDeleted(state) {
|
||||
debug("[FIND-DEBUG] GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus)
|
||||
continue
|
||||
}
|
||||
found = append(found, *state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если быстрый поиск не дал результатов — пагинированный fallback
|
||||
if len(found) == 0 {
|
||||
debug("[FIND-DEBUG] search path failed, entering fallback (serviceId=%d, name=%q)", serviceId, displayName)
|
||||
const maxFallbackPages = 100
|
||||
hitPageLimit := false
|
||||
page := 1
|
||||
for {
|
||||
reqURL := c.buildURL(fmt.Sprintf("/instances?page=%d&size=100", page))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
if c.ApiToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||||
}
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
debug("[FIND-DEBUG] fallback page=%d HttpClient.Do err: %v", page, err)
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
debug("[FIND-DEBUG] fallback page=%d HTTP %d", page, resp.StatusCode)
|
||||
return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Results []struct {
|
||||
InstanceUid string `json:"instanceUid"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ServiceId int `json:"serviceId"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
debug("[FIND-DEBUG] fallback page=%d json.Decode err: %v", page, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(res.Results) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
debug("[FIND-DEBUG] fallback page=%d has %d results", page, len(res.Results))
|
||||
for _, item := range res.Results {
|
||||
matchSvc := item.ServiceId == serviceId
|
||||
matchName := strings.EqualFold(item.DisplayName, displayName)
|
||||
if matchSvc || matchName {
|
||||
debug("[FIND-DEBUG] fallback item uid=%s name=%q svcId=%d matchSvc=%v matchName=%v",
|
||||
item.InstanceUid, item.DisplayName, item.ServiceId, matchSvc, matchName)
|
||||
}
|
||||
if matchSvc && matchName {
|
||||
state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid)
|
||||
if err != nil {
|
||||
debug("[FIND-DEBUG] fallback GetInstanceStateRaw err for uid=%s: %v", item.InstanceUid, err)
|
||||
continue
|
||||
}
|
||||
if state == nil {
|
||||
debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned nil for uid=%s", item.InstanceUid)
|
||||
continue
|
||||
}
|
||||
if isInstanceDeleted(state) {
|
||||
debug("[FIND-DEBUG] fallback GetInstanceStateRaw returned deleted for uid=%s (status=%q)", item.InstanceUid, state.ExplainedStatus)
|
||||
continue
|
||||
}
|
||||
found = append(found, *state)
|
||||
}
|
||||
}
|
||||
|
||||
page++
|
||||
if page > maxFallbackPages {
|
||||
hitPageLimit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hitPageLimit && len(found) == 0 {
|
||||
return nil, fmt.Errorf("поиск инстанса по display_name достиг лимита пагинации (%d страниц); сузьте фильтр или повторите с более точными параметрами", maxFallbackPages)
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(found) == 1 {
|
||||
return &found[0], nil
|
||||
}
|
||||
|
||||
// Множественные non-deleted инстансы — ошибка
|
||||
details := make([]string, 0, len(found))
|
||||
for _, inst := range found {
|
||||
details = append(details, fmt.Sprintf(" - %s (статус: %s)", inst.InstanceUid, inst.ExplainedStatus))
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"обнаружено %d инстансов с именем '%s' (serviceId=%d):\n%s\nНевозможно определить какой adopt-ить. Удалите лишние через ЛК (Управление облаком) или укажите конкретный UUID через 'terraform import'",
|
||||
len(found), displayName, serviceId, strings.Join(details, "\n"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ===== Чтение и валидация состояния инстанса =====
|
||||
|
||||
func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) {
|
||||
state, err := c.getInstanceStateWithRetry(ctx, instanceUid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInstanceStatus(state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func isInstanceDeleted(state *InstanceStateResponse) bool {
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
if state.IsDeleted {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(state.ExplainedStatus), "deleted")
|
||||
}
|
||||
|
||||
// GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса.
|
||||
// Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы.
|
||||
func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) {
|
||||
return c.getInstanceStateWithRetry(ctx, instanceUid)
|
||||
}
|
||||
|
||||
func (c *UniversalClient) getInstanceStateWithRetry(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) {
|
||||
respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Instance InstanceStateResponse `json:"instance"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &res.Instance, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ===== Логирование и debug-утилиты =====
|
||||
//
|
||||
// - ctxKeyLogLevel / CtxWithLogLevel — переопределение LogLevel на уровне ресурса
|
||||
// - debugLogPath / isHTTPDebugEnabled — файловый debug HTTP (NUBES_DEBUG_HTTP=1)
|
||||
// - sanitizeAuthHeader — маскирование Authorization в дампах
|
||||
// - formatStageMsg — форматирование stageMsg операции
|
||||
|
||||
// ctxKeyLogLevel — ключ для переопределения LogLevel на уровне ресурса через context.WithValue.
|
||||
type ctxKeyLogLevelType struct{}
|
||||
|
||||
var ctxKeyLogLevel = ctxKeyLogLevelType{}
|
||||
|
||||
func debugLogPath(fileName string) string {
|
||||
if dir := strings.TrimSpace(os.Getenv("NUBES_DEBUG_DIR")); dir != "" {
|
||||
return filepath.Join(dir, fileName)
|
||||
}
|
||||
return filepath.Join(os.TempDir(), fileName)
|
||||
}
|
||||
|
||||
func isHTTPDebugEnabled() bool {
|
||||
return os.Getenv("NUBES_DEBUG_HTTP") == "1"
|
||||
}
|
||||
|
||||
func sanitizeAuthHeader(h http.Header) http.Header {
|
||||
cloned := h.Clone()
|
||||
if cloned.Get("Authorization") != "" {
|
||||
cloned.Set("Authorization", "Bearer [REDACTED]")
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// CtxWithLogLevel возвращает ctx с переопределённым уровнем логирования.
|
||||
func CtxWithLogLevel(ctx context.Context, level string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyLogLevel, level)
|
||||
}
|
||||
|
||||
// reTimestamp удаляет временны́е метки вида [2026-03-24T05:07:47.716Z +0.860s] из строк.
|
||||
var reTimestamp = regexp.MustCompile(`\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[^\]]+\]\s*`)
|
||||
|
||||
// formatStageMsg парсит stageMsg (JSON-массив пар [имя, текст]) и возвращает
|
||||
// отформатированные строки для вывода. Временны́е метки убираются.
|
||||
func formatStageMsg(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
// Пытаемся распарсить как [["name","text"],...]
|
||||
var pairs [][]string
|
||||
if err := json.Unmarshal([]byte(raw), &pairs); err != nil || len(pairs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Вычисляем максимальную ширину имени для выравнивания
|
||||
maxLen := 0
|
||||
for _, p := range pairs {
|
||||
if len(p) >= 1 && len(p[0]) > maxLen {
|
||||
maxLen = len(p[0])
|
||||
}
|
||||
}
|
||||
var result []string
|
||||
for _, p := range pairs {
|
||||
if len(p) < 2 {
|
||||
continue
|
||||
}
|
||||
name := p[0]
|
||||
text := reTimestamp.ReplaceAllString(p[1], "")
|
||||
lines := strings.Split(strings.TrimSpace(text), "\n")
|
||||
padding := strings.Repeat(" ", maxLen-len(name))
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
result = append(result, fmt.Sprintf(" %s:%s %s", name, padding, line))
|
||||
} else {
|
||||
result = append(result, fmt.Sprintf(" %s %s", strings.Repeat(" ", maxLen), line))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// fetchOperationCfsParams возвращает cfsParams операции. При неудаче живого
|
||||
// GET /instanceOperations/{opUid}?fields=cfsParams (backend 500 в
|
||||
// getResourceRealmConfig на проблемных инстансах) берёт схему из
|
||||
// /instanceOperations/default/{opId}.
|
||||
func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid string, opId int) ([]universalCfsParam, error) {
|
||||
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
|
||||
if err == nil {
|
||||
var opDetails universalOpResponse
|
||||
if uerr := json.Unmarshal(opDetailsResp, &opDetails); uerr == nil {
|
||||
return opDetails.InstanceOperation.CfsParams, nil
|
||||
}
|
||||
}
|
||||
|
||||
defResp, _, defErr := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil)
|
||||
if defErr != nil {
|
||||
return nil, fmt.Errorf("не удалось получить детали операции: %w", defErr)
|
||||
}
|
||||
var def universalOpDefaultResponse
|
||||
if uerr := json.Unmarshal(defResp, &def); uerr != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать детали операции: %w", uerr)
|
||||
}
|
||||
return def.SvcOperation.CfsParams, nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ===== Запуск операций инстанса (params по ID) =====
|
||||
//
|
||||
// - RunInstanceOperationUniversal — операция с params по ID (без досылки дефолтов)
|
||||
// - RunInstanceOperationUniversalWithDefaults — то же + досылка required-дефолтов
|
||||
// - RunRedeployOperation — redeploy
|
||||
//
|
||||
// Операции с params по code — см. operation_run_bycode.go.
|
||||
// Схема cfsParams (с fallback) — см. operation_cfs.go.
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params, err = c.resolveRefSvcParamValues(ctx, 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 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)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
codeToParam := make(map[string]universalCfsParam)
|
||||
for _, p := range 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, 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 cfsParams {
|
||||
if sent[param.SvcOperationCfsParamId] {
|
||||
continue
|
||||
}
|
||||
// Дозаполняем ВСЕ незаданные параметры их текущим live-значением
|
||||
// (ParamValue из GET ?fields=cfsParams), а не только required.
|
||||
// Бэкенд для modify трактует пропущенный/null как reset-to-default,
|
||||
// из-за чего частичный payload затирал create-поля (см. prompt_for_opus_modifier_null_bug.md).
|
||||
// Поведение выровнено под RunInstanceOperationUniversalWithDefaults.
|
||||
// НО: если ни live-значения, ни дефолта НЕТ — пропускаем (не шлём синтетический
|
||||
// "0"/"false"/"[]", который может нарушить constraint "integer > 0").
|
||||
if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") &&
|
||||
(param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") {
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ===== Ожидание операций (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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ===== Нормализация значений CFS-параметров =====
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ===== Резолв refSvc-параметров (по ID) =====
|
||||
//
|
||||
// NOTE(resourceRealm): доступные resourceRealm для сервиса получают через
|
||||
// GET /resourceRealms/available?svcId=<serviceId> (через index.cfm proxy).
|
||||
//
|
||||
// NOTE(refSvcId lookup): если параметр операции create ссылается на другой сервис,
|
||||
// то в cfsParams будет проставлен refSvcId — значение должно быть UUID инстанса
|
||||
// указанного сервиса.
|
||||
//
|
||||
// ВАЖНО (uuid-case, 2026-03): API облака возвращает UUID всегда в нижнем регистре.
|
||||
// Для отправки в API нормализуем к lowercase, но в state сохраняем регистр
|
||||
// пользователя (см. шаблон instance.go, блоки "Restore user-provided casing").
|
||||
|
||||
func (c *UniversalClient) resolveRefSvcParamValues(ctx context.Context, opParams []universalCfsParam, params map[int]string) (map[int]string, error) {
|
||||
if len(params) == 0 || len(opParams) == 0 {
|
||||
return params, nil
|
||||
}
|
||||
|
||||
refById := make(map[int]int)
|
||||
for _, p := range opParams {
|
||||
if p.RefSvcId != nil && *p.RefSvcId > 0 {
|
||||
refById[p.SvcOperationCfsParamId] = *p.RefSvcId
|
||||
}
|
||||
}
|
||||
if len(refById) == 0 {
|
||||
// Даже без refSvcId — резолвим s3Uid внутри map-fixed (соглашение об именах).
|
||||
return c.ResolveS3UidInAllMapFixed(params, opParams), nil
|
||||
}
|
||||
|
||||
resolved := make(map[int]string, len(params))
|
||||
for paramId, value := range params {
|
||||
newValue := value
|
||||
if refSvcId, ok := refById[paramId]; ok {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
newValue = ""
|
||||
} else if !isUUIDLike(value) {
|
||||
uid, err := c.findInstanceUidByDisplayNameRefSvc(ctx, refSvcId, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uid == "" {
|
||||
return nil, fmt.Errorf("не удалось разрешить параметр %d: не найден экземпляр для serviceId=%d displayName=%s", paramId, refSvcId, value)
|
||||
}
|
||||
newValue = uid
|
||||
} else {
|
||||
newValue = strings.ToLower(value)
|
||||
}
|
||||
} else if strings.TrimSpace(value) != "" {
|
||||
// map-fixed: резолвим s3Uid-подобные uuid sub-params внутри JSON.
|
||||
newValue = c.resolveS3UidInMapFixed(paramId, value, opParams)
|
||||
}
|
||||
resolved[paramId] = newValue
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// ResolveS3UidInAllMapFixed проходит по всем параметрам и для map-fixed
|
||||
// резолвит s3Uid-подобные значения в UUID. Используется когда нет ни одного
|
||||
// параметра с refSvcId (ранний выход resolveRefSvcParamValues).
|
||||
func (c *UniversalClient) ResolveS3UidInAllMapFixed(params map[int]string, opParams []universalCfsParam) map[int]string {
|
||||
result := make(map[int]string, len(params))
|
||||
for paramId, value := range params {
|
||||
if strings.TrimSpace(value) != "" && value != "{}" && strings.HasPrefix(strings.TrimSpace(value), "{") {
|
||||
result[paramId] = c.resolveS3UidInMapFixed(paramId, value, opParams)
|
||||
} else {
|
||||
result[paramId] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveS3UidInMapFixed парсит JSON map-fixed-параметра, находит ключи
|
||||
// по паттерну s3.*uid (case-insensitive) и резолвит значения-не-UUID
|
||||
// в UUID через S3 (сервис 12). Соглашение об именах: s3Uid, s3_uid, S3Uid → S3.
|
||||
func (c *UniversalClient) resolveS3UidInMapFixed(paramId int, jsonValue string, opParams []universalCfsParam) string {
|
||||
if jsonValue == "" || jsonValue == "{}" {
|
||||
return jsonValue
|
||||
}
|
||||
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonValue), &obj); err != nil {
|
||||
return jsonValue
|
||||
}
|
||||
|
||||
changed := false
|
||||
for key, val := range obj {
|
||||
lowKey := strings.ToLower(key)
|
||||
if !strings.Contains(lowKey, "s3") || !strings.Contains(lowKey, "uid") {
|
||||
continue
|
||||
}
|
||||
strVal, ok := val.(string)
|
||||
if !ok || strVal == "" || isUUIDLike(strVal) {
|
||||
continue
|
||||
}
|
||||
uid, err := c.findInstanceUidByDisplayNameRefSvc(context.Background(), 12, strVal)
|
||||
if err != nil || uid == "" {
|
||||
continue
|
||||
}
|
||||
obj[key] = uid
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return jsonValue
|
||||
}
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return jsonValue
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ===== Поиск инстансов для refSvc (по displayName/UUID, с учётом сервиса) =====
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package core
|
||||
|
||||
// ===== DTO / API-контракты Nubes API =====
|
||||
//
|
||||
// Здесь только структуры (без логики):
|
||||
// - generic* — простые payload'ы создания инстанса/операции/параметра
|
||||
// - universal* — ответы схемы операций (cfsParams) и параметры
|
||||
// - InstanceStateResponse / ApiOperation — состояние инстанса
|
||||
// - operationStatusResponse / opStage — статус операции при поллинге
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// universalOpDefaultResponse — схема операции из GET /instanceOperations/default/{opId}.
|
||||
// Не вычисляет instance-специфичные выражения (getResourceRealmConfig) и не падает
|
||||
// с 500 на проблемных инстансах (см. docs/DEBUG_REPORT_VC_VDC_500.md).
|
||||
type universalOpDefaultResponse struct {
|
||||
SvcOperation universalOperation `json:"svcOperation"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// ===== Instance state =====
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// ===== Operation status (polling) =====
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// ===== UUID-утилиты =====
|
||||
|
||||
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')
|
||||
}
|
||||
Reference in New Issue
Block a user