diff --git a/devops/01_generate_yamls.sh b/devops/01_generate_yamls.sh index fd16b5d..0a55757 100755 --- a/devops/01_generate_yamls.sh +++ b/devops/01_generate_yamls.sh @@ -229,7 +229,7 @@ PY NUBES_SERVICE_NAME="$svc_name" \ NUBES_API_ENDPOINT="$API_ENDPOINT" \ NUBES_OUTPUT_DIR="$YAML_OUTPUT_DIR" \ - go run ./tools/service_spec_gen + ./bin/service_spec_gen ) && success=1 || success=0 if [[ "$success" -eq 1 ]]; then diff --git a/universal_rebuild/.gitignore b/universal_rebuild/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/universal_rebuild/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/universal_rebuild/tools/service_spec_gen/client/client.go b/universal_rebuild/tools/service_spec_gen/client/client.go new file mode 100644 index 0000000..4b91479 --- /dev/null +++ b/universal_rebuild/tools/service_spec_gen/client/client.go @@ -0,0 +1,225 @@ +// Package client — HTTP-клиент для API Nubes. +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "terraform-provider-nubes/tools/service_spec_gen/normalize" + "terraform-provider-nubes/tools/service_spec_gen/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 получает детали операции (параметры + MAN). +func (c *Client) GetServiceOperation(svcOperationID int) (types.ServiceOperationInfo, error) { + endpoint := fmt.Sprintf("/serviceOperation/%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) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Terraform-Provider-Nubes/Generator)") + 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") +} + +// 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 { + params = append(params, 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, + }) + } + 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 + } +} diff --git a/universal_rebuild/tools/service_spec_gen/config/config.go b/universal_rebuild/tools/service_spec_gen/config/config.go new file mode 100644 index 0000000..4182cdb --- /dev/null +++ b/universal_rebuild/tools/service_spec_gen/config/config.go @@ -0,0 +1,193 @@ +// Package config — загрузка конфигурации, токенов и списка сервисов. +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "terraform-provider-nubes/tools/service_spec_gen/normalize" +) + +// Config — конфигурация генератора. +type Config struct { + APIEndpoint string + APIToken string + Services []ServiceRef + SingleServiceID int + SingleServiceName string + OutputDir string +} + +// ServiceRef — ссылка на сервис (ID + имя). +type ServiceRef struct { + ID int + Name string +} + +// Load читает конфигурацию из переменных окружения и файлов. +func Load() (Config, error) { + apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm") + apiEndpoint = normalize.APIEndpoint(apiEndpoint) + apiToken, err := loadToken() + if err != nil { + return Config{}, err + } + + singleID := 0 + if raw := strings.TrimSpace(os.Getenv("NUBES_SERVICE_ID")); raw != "" { + val, err := strconv.Atoi(raw) + if err != nil { + return Config{}, fmt.Errorf("invalid NUBES_SERVICE_ID: %s", raw) + } + singleID = val + } + + singleName := strings.TrimSpace(os.Getenv("NUBES_SERVICE_NAME")) + outputDir := strings.TrimSpace(os.Getenv("NUBES_OUTPUT_DIR")) + + services := []ServiceRef{} + if singleID == 0 { + listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE")) + if listPath == "" { + repoRoot, err := FindRepoRoot() + if err != nil { + return Config{}, err + } + listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt") + } + list, err := ReadServicesList(listPath) + if err != nil { + return Config{}, err + } + services = list + } + + return Config{ + APIEndpoint: apiEndpoint, + APIToken: apiToken, + Services: services, + SingleServiceID: singleID, + SingleServiceName: singleName, + OutputDir: outputDir, + }, nil +} + +func getenvDefault(key string, def string) string { + val := strings.TrimSpace(os.Getenv(key)) + if val == "" { + return def + } + return val +} + +func loadToken() (string, error) { + if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" { + return tok, nil + } + if tf := strings.TrimSpace(os.Getenv("TOKEN_FILE")); tf != "" { + b, err := os.ReadFile(tf) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil + } + repoRoot, err := FindRepoRoot() + if err != nil { + return "", err + } + latest, err := findLatestToken(repoRoot) + if err != nil { + return "", err + } + if latest == "" { + return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required") + } + b, err := os.ReadFile(latest) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +func findLatestToken(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + var latest string + var latestTime int64 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + mt := info.ModTime().Unix() + if mt > latestTime { + latestTime = mt + latest = filepath.Join(dir, e.Name()) + } + } + return latest, nil +} + +// ReadServicesList читает список сервисов из файла. +func ReadServicesList(path string) ([]ServiceRef, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + lines := strings.Split(string(b), "\n") + services := []ServiceRef{} + for _, raw := range lines { + line := strings.TrimSpace(strings.ReplaceAll(raw, "\r", "")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, "#") { + line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0]) + } + parts := strings.Fields(line) + if len(parts) == 0 { + continue + } + id, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid service id in %s: %s", path, parts[0]) + } + name := "" + if len(parts) > 1 { + name = parts[1] + } + services = append(services, ServiceRef{ID: id, Name: name}) + } + return services, nil +} + +// FindRepoRoot ищет корень репозитория по директории universal_rebuild. +func FindRepoRoot() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + current := wd + for i := 0; i < 8; i++ { + candidate := filepath.Join(current, "universal_rebuild") + info, err := os.Stat(candidate) + if err == nil && info.IsDir() { + return candidate, nil + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return "", errors.New("failed to locate universal_rebuild repo root") +} diff --git a/universal_rebuild/tools/service_spec_gen/generate_service_spec.go b/universal_rebuild/tools/service_spec_gen/generate_service_spec.go deleted file mode 100644 index 3e409d3..0000000 --- a/universal_rebuild/tools/service_spec_gen/generate_service_spec.go +++ /dev/null @@ -1,746 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "gopkg.in/yaml.v3" -) - -// service_spec_gen: builds unified service YAML from API. -// -// Design goals: -// - One YAML per service, stored in resources_yaml. -// - The YAML includes all instance operations, subresource operations, and actions. -// - MAN and parameter metadata are embedded in the same YAML so docs and Go -// generators have a single source of truth. -// -// Env: -// - NUBES_API_TOKEN (preferred) or TOKEN_FILE -// - NUBES_API_ENDPOINT (default: https://deck-api.ngcloud.ru/api/v1/index.cfm) -// Supports two modes, auto-detected: -// - Proxy (legacy): contains "index.cfm" → ?endpoint=/services/123 -// - REST (new API Gateway): no "index.cfm" → /api/v1/svc/services/123 -// - NUBES_SERVICE_ID (optional for single service) -// - NUBES_SERVICE_NAME (optional override for single service) -// - NUBES_SERVICES_FILE (default: devops/config/services_list.txt) -// - NUBES_OUTPUT_DIR (default: /resources_yaml) - -func main() { - // Load env and services list configuration first so we can derive output paths. - cfg, err := loadConfig() - if err != nil { - panic(err) - } - - // API client uses proxy endpoint with bearer token. - client := newAPIClient(cfg.ApiEndpoint, cfg.ApiToken) - - // If a single service is requested, override the list from services_list.txt. - services := cfg.Services - if cfg.SingleServiceID > 0 { - services = []serviceRef{{ID: cfg.SingleServiceID, Name: cfg.SingleServiceName}} - } - - // Unified YAML output directory. We never emit resources_ops_yaml anymore. - outputDir := cfg.OutputDir - if outputDir == "" { - // Resolve the repo root only if we need the default output dir. - repoRoot, err := findRepoRoot() - if err != nil { - panic(err) - } - outputDir = filepath.Join(repoRoot, "resources_yaml") - } - if err := validateOutputDir(outputDir); err != nil { - panic(err) - } - if err := os.MkdirAll(outputDir, 0o755); err != nil { - panic(err) - } - - for _, svc := range services { - if svc.ID <= 0 { - continue - } - // Fetch service metadata and the operation list. - info, err := client.getService(svc.ID) - if err != nil { - fmt.Fprintf(os.Stderr, "WARNING: service %d (%s) skipped: %v\n", svc.ID, svc.Name, err) - continue - } - // Prefer the explicit name from services_list.txt; otherwise derive from API. - name := strings.TrimSpace(svc.Name) - if name == "" { - name = strings.TrimSpace(info.ShortName) - } - if name == "" { - name = strings.TrimSpace(info.DisplayName) - } - name = normalizeServiceName(name) - if name == "" { - name = fmt.Sprintf("service_%d", svc.ID) - } - - // Collect operations and derive lifecycle defaults based on suspend/resume. - ops, hasSuspend, _, _, err := client.collectOperations(info.Operations) - if err != nil { - panic(err) - } - - suspendOnDestroyDefault := hasSuspend - adoptExistingOnCreateDefault := false - - // Build the unified service YAML spec. This is the only YAML for the service. - spec := ServiceSpec{ - Name: name, - ServiceID: svc.ID, - ServiceDisplayName: strings.TrimSpace(info.DisplayName), - ServiceShortName: strings.TrimSpace(info.ShortName), - ServiceMan: strings.TrimSpace(info.Man), - Lifecycle: Lifecycle{ - SuspendOnDestroyDefault: suspendOnDestroyDefault, - AdoptExistingOnCreateDefault: adoptExistingOnCreateDefault, - }, - Outputs: OutputSection{Params: defaultOutputParams()}, - Operations: ops, - } - - // Write _.yaml to resources_yaml. - outPath := filepath.Join(outputDir, fmt.Sprintf("%d_%s.yaml", svc.ID, name)) - buf, err := yaml.Marshal(spec) - if err != nil { - panic(err) - } - if err := os.WriteFile(outPath, buf, 0o644); err != nil { - panic(err) - } - fmt.Printf("written %s\n", outPath) - } -} - -// ===== config ===== - -type config struct { - ApiEndpoint string - ApiToken string - Services []serviceRef - SingleServiceID int - SingleServiceName string - OutputDir string -} - -type serviceRef struct { - ID int - Name string -} - -func loadConfig() (config, error) { - // API endpoint defaults to production (legacy proxy); token is required. - // New API Gateway (REST) endpoints are also supported — auto-detected by presence of "index.cfm". - apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm") - apiEndpoint = normalizeAPIEndpoint(apiEndpoint) - apiToken, err := loadToken() - if err != nil { - return config{}, err - } - - // Optional single-service mode for debugging and local inspection. - singleID := 0 - if raw := strings.TrimSpace(os.Getenv("NUBES_SERVICE_ID")); raw != "" { - val, err := strconv.Atoi(raw) - if err != nil { - return config{}, fmt.Errorf("invalid NUBES_SERVICE_ID: %s", raw) - } - singleID = val - } - - singleName := strings.TrimSpace(os.Getenv("NUBES_SERVICE_NAME")) - outputDir := strings.TrimSpace(os.Getenv("NUBES_OUTPUT_DIR")) - - services := []serviceRef{} - if singleID == 0 { - // services_list.txt is the source of truth for which services to generate. - listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE")) - if listPath == "" { - repoRoot, err := findRepoRoot() - if err != nil { - return config{}, err - } - listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt") - } - list, err := readServicesList(listPath) - if err != nil { - return config{}, err - } - services = list - } - - return config{ - ApiEndpoint: apiEndpoint, - ApiToken: apiToken, - Services: services, - SingleServiceID: singleID, - SingleServiceName: singleName, - OutputDir: outputDir, - }, nil -} - -func getenvDefault(key string, def string) string { - val := strings.TrimSpace(os.Getenv(key)) - if val == "" { - return def - } - return val -} - -func loadToken() (string, error) { - // Prefer explicit env token, then TOKEN_FILE, then the latest *.token. - if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" { - return tok, nil - } - if tf := strings.TrimSpace(os.Getenv("TOKEN_FILE")); tf != "" { - b, err := os.ReadFile(tf) - if err != nil { - return "", err - } - return strings.TrimSpace(string(b)), nil - } - repoRoot, err := findRepoRoot() - if err != nil { - return "", err - } - latest, err := findLatestToken(repoRoot) - if err != nil { - return "", err - } - if latest == "" { - return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required") - } - b, err := os.ReadFile(latest) - if err != nil { - return "", err - } - return strings.TrimSpace(string(b)), nil -} - -func findLatestToken(dir string) (string, error) { - // Pick the newest *.token file by mtime. - entries, err := os.ReadDir(dir) - if err != nil { - return "", err - } - var latest string - var latestTime int64 - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") { - continue - } - info, err := e.Info() - if err != nil { - continue - } - mt := info.ModTime().Unix() - if mt > latestTime { - latestTime = mt - latest = filepath.Join(dir, e.Name()) - } - } - return latest, nil -} - -func readServicesList(path string) ([]serviceRef, error) { - // Each non-empty line is: [# comment] - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - lines := strings.Split(string(b), "\n") - services := []serviceRef{} - for _, raw := range lines { - line := strings.TrimSpace(strings.ReplaceAll(raw, "\r", "")) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if strings.Contains(line, "#") { - line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0]) - } - parts := strings.Fields(line) - if len(parts) == 0 { - continue - } - id, err := strconv.Atoi(parts[0]) - if err != nil { - return nil, fmt.Errorf("invalid service id in %s: %s", path, parts[0]) - } - name := "" - if len(parts) > 1 { - name = parts[1] - } - services = append(services, serviceRef{ID: id, Name: name}) - } - return services, nil -} - -func findRepoRoot() (string, error) { - // We locate the repo by finding the universal_rebuild directory. - wd, err := os.Getwd() - if err != nil { - return "", err - } - current := wd - for i := 0; i < 8; i++ { - candidate := filepath.Join(current, "universal_rebuild") - info, err := os.Stat(candidate) - if err == nil && info.IsDir() { - return candidate, nil - } - parent := filepath.Dir(current) - if parent == current { - break - } - current = parent - } - return "", errors.New("failed to locate universal_rebuild repo root") -} - -// ===== api client ===== - -type apiClient struct { - endpoint string - token string - httpClient *http.Client -} - -type serviceResponse struct { - Service serviceInfo `json:"svc"` -} - -type serviceInfo struct { - ID int `json:"svcId"` - DisplayName string `json:"svc"` - ShortName string `json:"svcShort"` - Man string `json:"man"` - Operations []operationInfo `json:"operations"` -} - -type operationInfo struct { - SvcOperationId int `json:"svcOperationId"` - Operation string `json:"operation"` -} - -type serviceOperationResponse struct { - ServiceOperation serviceOperationInfo `json:"svcOperation"` -} - -type serviceOperationInfo struct { - SvcOperationId int `json:"svcOperationId"` - Operation string `json:"operation"` - Man string `json:"man"` - CfsParams []cfsParam `json:"cfsParams"` -} - -type cfsParam struct { - ID int `json:"svcOperationCfsParamId"` - Code string `json:"svcOperationCfsParam"` - DataType string `json:"dataType"` - ValueList []interface{} `json:"valueList"` - RefSvcId *int `json:"refSvcId"` - IsRequired bool `json:"isRequired"` - DefaultValue interface{} `json:"defaultValue"` - Func string `json:"func"` - Regex string `json:"regex"` - UniqueScope string `json:"uniqueScope"` - MaxLength *int `json:"maxlength"` - MinLength *int `json:"minlength"` - MaxValue interface{} `json:"maxvalue"` - MinValue interface{} `json:"minvalue"` - Descr string `json:"descr"` - Man string `json:"man"` - Sort *int `json:"sort"` - DependsOnCfsParams interface{} `json:"dependsOnCfsParams"` - IsModifiable *bool `json:"isModifiable"` - IsSensitive bool `json:"isSensitive"` -} - -func (c *apiClient) getService(serviceID int) (serviceInfo, error) { - // /services/{id} returns display names, MAN, and a list of operations. - endpoint := fmt.Sprintf("/services/%d", serviceID) - var res serviceResponse - if err := c.callAPI(endpoint, &res); err != nil { - return serviceInfo{}, err - } - return res.Service, nil -} - -func (c *apiClient) getServiceOperation(svcOperationId int) (serviceOperationInfo, error) { - // /serviceOperation/{id} returns params and MAN for a single operation. - endpoint := fmt.Sprintf("/serviceOperation/%d", svcOperationId) - var res serviceOperationResponse - if err := c.callAPI(endpoint, &res); err != nil { - return serviceOperationInfo{}, err - } - return res.ServiceOperation, nil -} - -func (c *apiClient) 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() { - // Legacy proxy: index.cfm?endpoint=/services/123 - reqURL = c.endpoint + "?endpoint=" + endpoint - } else { - // New REST gateway: /api/v1/svc/services/123 - 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) - } - req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Terraform-Provider-Nubes/Generator)") - 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))) - // Retry on transient errors - 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 *apiClient) collectOperations(ops []operationInfo) ([]OperationSpec, bool, bool, bool, error) { - // Resolve each operation in detail and classify it into instance/subresource/action. - result := make([]OperationSpec, 0, len(ops)) - hasSuspend := false - hasResume := false - hasDelete := false - for _, op := range ops { - opName := normalizeOperationName(op.Operation) - if opName == "" { - continue - } - opInfo, err := c.getServiceOperation(op.SvcOperationId) - if err != nil { - return nil, false, false, false, err - } - // Params are captured with full metadata, including MAN/descr/constraints. - params := make([]ParamSpec, 0, len(opInfo.CfsParams)) - for _, p := range opInfo.CfsParams { - params = append(params, 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, - }) - } - sort.Slice(params, func(i, j int) bool { return params[i].ID < params[j].ID }) - - // Operation name determines kind and action. - 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, 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) { - // Canonical classification rules: - // - create/modify/delete/suspend/resume (exact match) → instance - // - verb_subresource (any prefix with underscore) → subresource (verb: action, suffix: subresource name) - // - everything else → action - n := strings.ToLower(strings.TrimSpace(name)) - if n == "create" || n == "modify" || n == "delete" || n == "suspend" || n == "resume" { - return "instance", n, "" - } - // Any name with underscore → subresource (e.g. create_user, modify_database, restart_service) - 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 { - // YAML stores value_list as strings for stable docs rendering. - 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{} { - // Keep numeric defaults as-is, but trim strings. - if value == nil { - return nil - } - switch t := value.(type) { - case string: - return strings.TrimSpace(t) - default: - return value - } -} - -// ===== spec ===== - -type ServiceSpec struct { - Name string `yaml:"name"` - ServiceID int `yaml:"service_id"` - ServiceDisplayName string `yaml:"service_display_name,omitempty"` - ServiceShortName string `yaml:"service_short_name,omitempty"` - ServiceMan string `yaml:"service_man,omitempty"` - Lifecycle Lifecycle `yaml:"lifecycle"` - Outputs OutputSection `yaml:"outputs"` - Operations []OperationSpec `yaml:"operations"` -} - -type Lifecycle struct { - SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"` - AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"` -} - -type OutputSection struct { - Params []OutputParam `yaml:"params"` -} - -type OutputParam struct { - Code string `yaml:"code"` - Type string `yaml:"type"` - Sensitive bool `yaml:"sensitive,omitempty"` -} - -type OperationSpec struct { - Name string `yaml:"name"` - ID int `yaml:"id"` - Kind string `yaml:"kind"` - Action string `yaml:"action"` - Subresource string `yaml:"subresource,omitempty"` - Man string `yaml:"man,omitempty"` - Params []ParamSpec `yaml:"params"` -} - -type ParamSpec struct { - ID int `yaml:"id"` - Code string `yaml:"code"` - DataType string `yaml:"data_type,omitempty"` - Required bool `yaml:"required"` - Default interface{} `yaml:"default,omitempty"` - ValueList []string `yaml:"value_list,omitempty"` - RefSvcId *int `yaml:"ref_svc_id,omitempty"` - Func string `yaml:"func,omitempty"` - Regex string `yaml:"regex,omitempty"` - UniqueScope string `yaml:"unique_scope,omitempty"` - MaxLength *int `yaml:"maxlength,omitempty"` - MinLength *int `yaml:"minlength,omitempty"` - MaxValue interface{} `yaml:"maxvalue,omitempty"` - MinValue interface{} `yaml:"minvalue,omitempty"` - Descr string `yaml:"descr,omitempty"` - Man string `yaml:"man,omitempty"` - Sort *int `yaml:"sort,omitempty"` - DependsOn interface{} `yaml:"depends_on,omitempty"` - IsModifiable *bool `yaml:"is_modifiable,omitempty"` - IsSensitive bool `yaml:"is_sensitive,omitempty"` -} - -func defaultOutputParams() []OutputParam { - // Standard outputs expected by the provider core and docs. - return []OutputParam{ - {Code: "state_params", Type: "map"}, - {Code: "state_out", Type: "map"}, - {Code: "state_params_flat", Type: "map"}, - {Code: "state_out_flat", Type: "map"}, - {Code: "vault_secrets", Type: "map", Sensitive: true}, - {Code: "vault_url", Type: "string"}, - {Code: "vault_user_path", Type: "string"}, - {Code: "vault_fields", Type: "list"}, - } -} - -func validateOutputDir(path string) error { - // Guardrail: legacy ops YAML is deprecated; unified YAML only lives in resources_yaml. - base := strings.ToLower(strings.TrimSpace(filepath.Base(path))) - if base == "resources_ops_yaml" { - return fmt.Errorf("invalid output dir %q: use resources_yaml for unified service specs", path) - } - return nil -} - -func newAPIClient(endpoint string, token string) *apiClient { - // Short timeout prevents hanging forever on network or API stalls. - return &apiClient{ - endpoint: endpoint, - token: token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -func normalizeServiceName(raw string) string { - // Service name must be filesystem-safe and ASCII for stable file naming. - return normalizeIdentifier(raw) -} - -func normalizeOperationName(raw string) string { - // Normalize operation names into snake_case used by generators. - return normalizeIdentifier(raw) -} - -func normalizeIdentifier(raw string) string { - // Convert to ASCII snake_case: letters/digits kept, everything else becomes underscore. - // CamelCase is split into words by inserting underscores before uppercase letters. - if strings.TrimSpace(raw) == "" { - return "" - } - var out []rune - lastUnderscore := false - prevLowerOrDigit := false - for _, r := range raw { - if r >= 'A' && r <= 'Z' { - if prevLowerOrDigit && !lastUnderscore { - out = append(out, '_') - } - out = append(out, r+'a'-'A') - lastUnderscore = false - prevLowerOrDigit = true - continue - } - if r >= 'a' && r <= 'z' { - out = append(out, r) - lastUnderscore = false - prevLowerOrDigit = true - continue - } - if r >= '0' && r <= '9' { - out = append(out, r) - lastUnderscore = false - prevLowerOrDigit = true - continue - } - // Any non-ASCII or punctuation becomes a separator. - if !lastUnderscore && len(out) > 0 { - out = append(out, '_') - lastUnderscore = true - } - prevLowerOrDigit = false - } - result := strings.Trim(outToString(out), "_") - return collapseUnderscores(result) -} - -func outToString(runes []rune) string { - return string(runes) -} - -func collapseUnderscores(value string) string { - if value == "" { - return value - } - for strings.Contains(value, "__") { - value = strings.ReplaceAll(value, "__", "_") - } - return strings.Trim(value, "_") -} - -func normalizeAPIEndpoint(raw string) string { - // Accept both legacy proxy (index.cfm) and new REST gateway endpoints. - // Just trim trailing slash — no forced /index.cfm appending. - // The callAPI method auto-detects proxy vs REST mode by checking for "index.cfm". - return strings.TrimRight(strings.TrimSpace(raw), "/") -} - -// isProxyAPI returns true if the endpoint uses the legacy ?endpoint= proxy pattern. -func (c *apiClient) isProxyAPI() bool { - return strings.Contains(c.endpoint, "index.cfm") -} diff --git a/universal_rebuild/tools/service_spec_gen/main.go b/universal_rebuild/tools/service_spec_gen/main.go new file mode 100644 index 0000000..473658a --- /dev/null +++ b/universal_rebuild/tools/service_spec_gen/main.go @@ -0,0 +1,100 @@ +// service_spec_gen — генератор YAML-спеков сервисов из API Nubes. +// +// Читает список сервисов, дёргает API, собирает операции/параметры/MAN, +// пишет один YAML на сервис в resources_yaml/. +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "terraform-provider-nubes/tools/service_spec_gen/client" + "terraform-provider-nubes/tools/service_spec_gen/config" + "terraform-provider-nubes/tools/service_spec_gen/normalize" + "terraform-provider-nubes/tools/service_spec_gen/spec" + "terraform-provider-nubes/tools/service_spec_gen/types" +) + +func main() { + cfg, err := config.Load() + if err != nil { + panic(err) + } + + cli := client.New(cfg.APIEndpoint, cfg.APIToken) + + services := cfg.Services + if cfg.SingleServiceID > 0 { + services = []config.ServiceRef{{ID: cfg.SingleServiceID, Name: cfg.SingleServiceName}} + } + + outputDir := cfg.OutputDir + if outputDir == "" { + repoRoot, err := config.FindRepoRoot() + if err != nil { + panic(err) + } + outputDir = filepath.Join(repoRoot, "resources_yaml") + } + if err := spec.ValidateOutputDir(outputDir); err != nil { + panic(err) + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + panic(err) + } + + for _, svc := range services { + if svc.ID <= 0 { + continue + } + info, err := cli.GetService(svc.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: service %d (%s) skipped: %v\n", svc.ID, svc.Name, err) + continue + } + name := strings.TrimSpace(svc.Name) + if name == "" { + name = strings.TrimSpace(info.ShortName) + } + if name == "" { + name = strings.TrimSpace(info.DisplayName) + } + name = normalize.ServiceName(name) + if name == "" { + name = fmt.Sprintf("service_%d", svc.ID) + } + + ops, hasSuspend, _, _, err := cli.CollectOperations(info.Operations) + if err != nil { + panic(err) + } + + specYAML := types.ServiceSpec{ + Name: name, + ServiceID: svc.ID, + ServiceDisplayName: strings.TrimSpace(info.DisplayName), + ServiceShortName: strings.TrimSpace(info.ShortName), + ServiceMan: strings.TrimSpace(info.Man), + Lifecycle: types.Lifecycle{ + SuspendOnDestroyDefault: hasSuspend, + AdoptExistingOnCreateDefault: false, + }, + Outputs: types.OutputSection{Params: spec.DefaultOutputParams()}, + Operations: ops, + } + + outPath := filepath.Join(outputDir, fmt.Sprintf("%d_%s.yaml", svc.ID, name)) + buf, err := yaml.Marshal(specYAML) + if err != nil { + panic(err) + } + if err := os.WriteFile(outPath, buf, 0o644); err != nil { + panic(err) + } + fmt.Printf("written %s\n", outPath) + } +} diff --git a/universal_rebuild/tools/service_spec_gen/normalize/normalize.go b/universal_rebuild/tools/service_spec_gen/normalize/normalize.go new file mode 100644 index 0000000..5556a5b --- /dev/null +++ b/universal_rebuild/tools/service_spec_gen/normalize/normalize.go @@ -0,0 +1,69 @@ +// Package normalize — утилиты нормализации имён и endpoint'ов. +package normalize + +import "strings" + +// ServiceName нормализует имя сервиса для использования в именах файлов. +func ServiceName(raw string) string { + return Identifier(raw) +} + +// OperationName нормализует имя операции в snake_case. +func OperationName(raw string) string { + return Identifier(raw) +} + +// Identifier конвертирует строку в ASCII snake_case. +func Identifier(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + var out []rune + lastUnderscore := false + prevLowerOrDigit := false + for _, r := range raw { + if r >= 'A' && r <= 'Z' { + if prevLowerOrDigit && !lastUnderscore { + out = append(out, '_') + } + out = append(out, r+'a'-'A') + lastUnderscore = false + prevLowerOrDigit = true + continue + } + if r >= 'a' && r <= 'z' { + out = append(out, r) + lastUnderscore = false + prevLowerOrDigit = true + continue + } + if r >= '0' && r <= '9' { + out = append(out, r) + lastUnderscore = false + prevLowerOrDigit = true + continue + } + if !lastUnderscore && len(out) > 0 { + out = append(out, '_') + lastUnderscore = true + } + prevLowerOrDigit = false + } + result := strings.Trim(string(out), "_") + return collapseUnderscores(result) +} + +// APIEndpoint нормализует URL API-эндпоинта (убирает trailing slash). +func APIEndpoint(raw string) string { + return strings.TrimRight(strings.TrimSpace(raw), "/") +} + +func collapseUnderscores(value string) string { + if value == "" { + return value + } + for strings.Contains(value, "__") { + value = strings.ReplaceAll(value, "__", "_") + } + return strings.Trim(value, "_") +} diff --git a/universal_rebuild/tools/service_spec_gen/spec/spec.go b/universal_rebuild/tools/service_spec_gen/spec/spec.go new file mode 100644 index 0000000..4327dd7 --- /dev/null +++ b/universal_rebuild/tools/service_spec_gen/spec/spec.go @@ -0,0 +1,33 @@ +// Package spec — построение YAML-спецификации сервиса. +package spec + +import ( + "fmt" + "path/filepath" + "strings" + + "terraform-provider-nubes/tools/service_spec_gen/types" +) + +// DefaultOutputParams возвращает стандартный набор выходных параметров. +func DefaultOutputParams() []types.OutputParam { + return []types.OutputParam{ + {Code: "state_params", Type: "map"}, + {Code: "state_out", Type: "map"}, + {Code: "state_params_flat", Type: "map"}, + {Code: "state_out_flat", Type: "map"}, + {Code: "vault_secrets", Type: "map", Sensitive: true}, + {Code: "vault_url", Type: "string"}, + {Code: "vault_user_path", Type: "string"}, + {Code: "vault_fields", Type: "list"}, + } +} + +// ValidateOutputDir проверяет, что выходная директория не legacy resources_ops_yaml. +func ValidateOutputDir(path string) error { + base := strings.ToLower(strings.TrimSpace(filepath.Base(path))) + if base == "resources_ops_yaml" { + return fmt.Errorf("invalid output dir %q: use resources_yaml for unified service specs", path) + } + return nil +} diff --git a/universal_rebuild/tools/service_spec_gen/types/types.go b/universal_rebuild/tools/service_spec_gen/types/types.go new file mode 100644 index 0000000..99022d4 --- /dev/null +++ b/universal_rebuild/tools/service_spec_gen/types/types.go @@ -0,0 +1,128 @@ +// Package types — структуры данных для YAML-спеков сервисов. +package types + +// ─── API-ответы ───────────────────────────────────────────────────────────── + +// ServiceResponse — ответ /services/{id}. +type ServiceResponse struct { + Service ServiceInfo `json:"svc"` +} + +// ServiceInfo — метаданные сервиса из API. +type ServiceInfo struct { + ID int `json:"svcId"` + DisplayName string `json:"svc"` + ShortName string `json:"svcShort"` + Man string `json:"man"` + Operations []OperationInfo `json:"operations"` +} + +// OperationInfo — краткая информация об операции из списка. +type OperationInfo struct { + SvcOperationID int `json:"svcOperationId"` + Operation string `json:"operation"` +} + +// ServiceOperationResponse — ответ /serviceOperation/{id}. +type ServiceOperationResponse struct { + ServiceOperation ServiceOperationInfo `json:"svcOperation"` +} + +// ServiceOperationInfo — детальная информация об операции. +type ServiceOperationInfo struct { + SvcOperationID int `json:"svcOperationId"` + Operation string `json:"operation"` + Man string `json:"man"` + CfsParams []CfsParam `json:"cfsParams"` +} + +// CfsParam — параметр операции из API. +type CfsParam struct { + ID int `json:"svcOperationCfsParamId"` + Code string `json:"svcOperationCfsParam"` + DataType string `json:"dataType"` + ValueList []interface{} `json:"valueList"` + RefSvcID *int `json:"refSvcId"` + IsRequired bool `json:"isRequired"` + DefaultValue interface{} `json:"defaultValue"` + Func string `json:"func"` + Regex string `json:"regex"` + UniqueScope string `json:"uniqueScope"` + MaxLength *int `json:"maxlength"` + MinLength *int `json:"minlength"` + MaxValue interface{} `json:"maxvalue"` + MinValue interface{} `json:"minvalue"` + Descr string `json:"descr"` + Man string `json:"man"` + Sort *int `json:"sort"` + DependsOnCfsParams interface{} `json:"dependsOnCfsParams"` + IsModifiable *bool `json:"isModifiable"` + IsSensitive bool `json:"isSensitive"` +} + +// ─── Выходные структуры (YAML) ────────────────────────────────────────────── + +// ServiceSpec — полная YAML-спецификация сервиса. +type ServiceSpec struct { + Name string `yaml:"name"` + ServiceID int `yaml:"service_id"` + ServiceDisplayName string `yaml:"service_display_name,omitempty"` + ServiceShortName string `yaml:"service_short_name,omitempty"` + ServiceMan string `yaml:"service_man,omitempty"` + Lifecycle Lifecycle `yaml:"lifecycle"` + Outputs OutputSection `yaml:"outputs"` + Operations []OperationSpec `yaml:"operations"` +} + +// Lifecycle — настройки жизненного цикла сервиса. +type Lifecycle struct { + SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"` + AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"` +} + +// OutputSection — секция выходных параметров. +type OutputSection struct { + Params []OutputParam `yaml:"params"` +} + +// OutputParam — выходной параметр (state_params, vault_secrets, ...). +type OutputParam struct { + Code string `yaml:"code"` + Type string `yaml:"type"` + Sensitive bool `yaml:"sensitive,omitempty"` +} + +// OperationSpec — одна операция в YAML-спеке. +type OperationSpec struct { + Name string `yaml:"name"` + ID int `yaml:"id"` + Kind string `yaml:"kind"` + Action string `yaml:"action"` + Subresource string `yaml:"subresource,omitempty"` + Man string `yaml:"man,omitempty"` + Params []ParamSpec `yaml:"params"` +} + +// ParamSpec — параметр операции в YAML-спеке. +type ParamSpec struct { + ID int `yaml:"id"` + Code string `yaml:"code"` + DataType string `yaml:"data_type,omitempty"` + Required bool `yaml:"required"` + Default interface{} `yaml:"default,omitempty"` + ValueList []string `yaml:"value_list,omitempty"` + RefSvcID *int `yaml:"ref_svc_id,omitempty"` + Func string `yaml:"func,omitempty"` + Regex string `yaml:"regex,omitempty"` + UniqueScope string `yaml:"unique_scope,omitempty"` + MaxLength *int `yaml:"maxlength,omitempty"` + MinLength *int `yaml:"minlength,omitempty"` + MaxValue interface{} `yaml:"maxvalue,omitempty"` + MinValue interface{} `yaml:"minvalue,omitempty"` + Descr string `yaml:"descr,omitempty"` + Man string `yaml:"man,omitempty"` + Sort *int `yaml:"sort,omitempty"` + DependsOn interface{} `yaml:"depends_on,omitempty"` + IsModifiable *bool `yaml:"is_modifiable,omitempty"` + IsSensitive bool `yaml:"is_sensitive,omitempty"` +}