330 lines
9.6 KiB
Go
330 lines
9.6 KiB
Go
// 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"
|
|
"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 _, op := range ops {
|
|
opName := normalize.OperationName(op.Operation)
|
|
if opName == "" {
|
|
continue
|
|
}
|
|
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
|
|
}
|