// Package client — HTTP-клиент для API Nubes. // // Поддерживает два режима API, автоопределяемых по URL: // - Legacy proxy: содержит "index.cfm" → ?endpoint=/services/123 // - REST Gateway: без "index.cfm" → /api/v1/svc/services/123 // // Ретраи: до 3 попыток с экспоненциальной задержкой (2s, 4s, 8s). // Ретраятся: сетевые ошибки, 429 (rate limit), 5xx. // // Все методы возвращают типы из пакета types. package client import ( "encoding/json" "fmt" "io" "net/http" "os" "sort" "strings" "time" "yaml-generator/internal/normalize" "yaml-generator/internal/types" ) // Client — HTTP-клиент для Nubes API. type Client struct { endpoint string token string httpClient *http.Client } // New создаёт новый API-клиент. func New(endpoint string, token string) *Client { return &Client{ endpoint: endpoint, token: token, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } // GetService получает метаданные сервиса. func (c *Client) GetService(serviceID int) (types.ServiceInfo, error) { endpoint := fmt.Sprintf("/services/%d", serviceID) var res types.ServiceResponse if err := c.callAPI(endpoint, &res); err != nil { return types.ServiceInfo{}, err } return res.Service, nil } // GetServiceOperation получает детали операции (параметры + dataDescriptor + valueList). // Использует /instanceOperations/default/{id} — метод Виталия (15.07.2026). // Этот эндпоинт возвращает dataDescriptor (подполя map-fixed) и valueList, // в отличие от /serviceOperation/{id} который даёт только плоский список. func (c *Client) GetServiceOperation(svcOperationID int) (types.ServiceOperationInfo, error) { endpoint := fmt.Sprintf("/instanceOperations/default/%d", svcOperationID) var res types.ServiceOperationResponse if err := c.callAPI(endpoint, &res); err != nil { return types.ServiceOperationInfo{}, err } return res.ServiceOperation, nil } func (c *Client) callAPI(endpoint string, out interface{}) error { const maxRetries = 3 baseDelay := 2 * time.Second var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { time.Sleep(baseDelay * time.Duration(1<<(attempt-1))) } var reqURL string if c.isProxyAPI() { reqURL = c.endpoint + "?endpoint=" + endpoint } else { reqURL = c.endpoint + endpoint } req, err := http.NewRequest("GET", reqURL, nil) if err != nil { return err } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } // DDoS-Guard требует браузерный User-Agent ВСЕГДА (и старый, и новый API). req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36") req.Header.Set("Referer", c.refererFromEndpoint()) resp, err := c.httpClient.Do(req) if err != nil { lastErr = err if attempt < maxRetries { continue } return err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { lastErr = readErr if attempt < maxRetries { continue } return readErr } if resp.StatusCode != 200 { err := fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) if attempt < maxRetries && (resp.StatusCode == 429 || resp.StatusCode >= 500) { lastErr = err continue } return err } return json.Unmarshal(body, out) } return fmt.Errorf("callAPI failed after %d retries: %w", maxRetries, lastErr) } func (c *Client) isProxyAPI() bool { return strings.Contains(c.endpoint, "index.cfm") } // refererFromEndpoint извлекает Referer из URL API (DDoS-Guard). // lk-api-gateway-test.ngcloud.ru → https://deck-test.ngcloud.ru/ // lk-api-gateway.ngcloud.ru → https://deck.ngcloud.ru/ func (c *Client) refererFromEndpoint() string { ep := c.endpoint if strings.HasPrefix(ep, "https://") { ep = ep[8:] } else if strings.HasPrefix(ep, "http://") { ep = ep[7:] } host := ep if idx := strings.Index(host, "/"); idx >= 0 { host = host[:idx] } // lk-api-gateway-stand → deck-stand, lk-api-gateway → deck refererHost := strings.Replace(host, "lk-api-gateway-", "deck-", 1) refererHost = strings.Replace(refererHost, "lk-api-gateway.", "deck.", 1) return "https://" + refererHost + "/" } // CollectOperations собирает и классифицирует все операции сервиса. func (c *Client) CollectOperations(ops []types.OperationInfo) ([]types.OperationSpec, bool, bool, bool, error) { result := make([]types.OperationSpec, 0, len(ops)) hasSuspend := false hasResume := false hasDelete := false for i, op := range ops { opName := normalize.OperationName(op.Operation) if opName == "" { continue } // Прогресс: выводим каждую операцию чтобы было видно что не зависло. fmt.Fprintf(os.Stderr, " [%d/%d] %s (id=%d)...\n", i+1, len(ops), opName, op.SvcOperationID) opInfo, err := c.GetServiceOperation(op.SvcOperationID) if err != nil { return nil, false, false, false, err } params := make([]types.ParamSpec, 0, len(opInfo.CfsParams)) for _, p := range opInfo.CfsParams { param := types.ParamSpec{ ID: p.ID, Code: p.Code, DataType: strings.TrimSpace(p.DataType), Required: p.IsRequired, Default: normalizeDefault(p.DefaultValue), ValueList: normalizeValueList(p.ValueList), RefSvcID: p.RefSvcID, Func: strings.TrimSpace(p.Func), Regex: strings.TrimSpace(p.Regex), UniqueScope: strings.TrimSpace(p.UniqueScope), MaxLength: p.MaxLength, MinLength: p.MinLength, MaxValue: normalizeDefault(p.MaxValue), MinValue: normalizeDefault(p.MinValue), Descr: strings.TrimSpace(p.Descr), Man: strings.TrimSpace(p.Man), Sort: p.Sort, DependsOn: p.DependsOnCfsParams, IsModifiable: p.IsModifiable, IsSensitive: p.IsSensitive, } // Разворачиваем dataDescriptor в SubParams (подполя map-fixed/array-map-fixed). if len(p.DataDescriptor) > 0 { subParams := make([]types.ParamSpec, 0, len(p.DataDescriptor)) for code, sub := range p.DataDescriptor { subParams = append(subParams, types.ParamSpec{ ID: sub.SubParamID, Code: code, DataType: strings.TrimSpace(sub.DataType), Required: sub.IsRequired, Default: normalizeDefault(sub.DefaultValue), // valueList в dataDescriptor — может быть строкой (comma-sep) или массивом ValueList: normalizeSubValueList(sub.ValueList), Descr: strings.TrimSpace(sub.Descr), Man: strings.TrimSpace(sub.Man), Regex: strings.TrimSpace(sub.Regex), // isModifiableDefinition → IsModifiable IsModifiable: boolPtr(sub.IsModifiableDefinition), IsSensitive: sub.IsSensitive, }) } param.SubParams = subParams } params = append(params, param) } sort.Slice(params, func(i, j int) bool { return params[i].ID < params[j].ID }) kind, action, subresource := classifyOperation(opName) if kind == "instance" && action == "suspend" { hasSuspend = true } if kind == "instance" && action == "resume" { hasResume = true } if kind == "instance" && action == "delete" { hasDelete = true } result = append(result, types.OperationSpec{ Name: opName, ID: opInfo.SvcOperationID, Kind: kind, Action: action, Subresource: subresource, Man: strings.TrimSpace(opInfo.Man), Params: params, }) } sort.Slice(result, func(i, j int) bool { if result[i].Name == result[j].Name { return result[i].ID < result[j].ID } return result[i].Name < result[j].Name }) return result, hasSuspend, hasResume, hasDelete, nil } func classifyOperation(name string) (string, string, string) { n := strings.ToLower(strings.TrimSpace(name)) if n == "create" || n == "modify" || n == "delete" || n == "suspend" || n == "resume" { return "instance", n, "" } if idx := strings.Index(n, "_"); idx > 0 { verb := n[:idx] sub := n[idx+1:] if sub != "" { return "subresource", verb, sub } } return "action", n, "" } func normalizeValueList(values []interface{}) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)) for _, v := range values { out = append(out, fmt.Sprintf("%v", v)) } return out } func normalizeDefault(value interface{}) interface{} { if value == nil { return nil } switch t := value.(type) { case string: return strings.TrimSpace(t) default: return value } } // normalizeSubValueList — приводит valueList из dataDescriptor к []string. // API может вернуть строку (comma-separated), массив, или null. func normalizeSubValueList(v interface{}) []string { if v == nil { return nil } switch t := v.(type) { case string: return splitCommaList(t) case []interface{}: out := make([]string, 0, len(t)) for _, item := range t { out = append(out, fmt.Sprintf("%v", item)) } return out default: return nil } } // splitCommaList — разбивает comma-separated строку из dataDescriptor в []string. func splitCommaList(s string) []string { s = strings.TrimSpace(s) if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } if len(out) == 0 { return nil } return out } // boolPtr — возвращает указатель на bool. func boolPtr(b bool) *bool { return &b }