refactor(service_spec_gen): split into 6 packages (types, client, config, normalize, spec, main) + .gitignore
- Split 741-line monolith into 6 independent packages - All functions exported, no circular deps - Build: go build -o bin/service_spec_gen ./tools/service_spec_gen/ - Script 01_generate_yamls.sh: go run → ./bin/service_spec_gen - Add bin/ to .gitignore
This commit is contained in:
@@ -229,7 +229,7 @@ PY
|
|||||||
NUBES_SERVICE_NAME="$svc_name" \
|
NUBES_SERVICE_NAME="$svc_name" \
|
||||||
NUBES_API_ENDPOINT="$API_ENDPOINT" \
|
NUBES_API_ENDPOINT="$API_ENDPOINT" \
|
||||||
NUBES_OUTPUT_DIR="$YAML_OUTPUT_DIR" \
|
NUBES_OUTPUT_DIR="$YAML_OUTPUT_DIR" \
|
||||||
go run ./tools/service_spec_gen
|
./bin/service_spec_gen
|
||||||
) && success=1 || success=0
|
) && success=1 || success=0
|
||||||
|
|
||||||
if [[ "$success" -eq 1 ]]; then
|
if [[ "$success" -eq 1 ]]; then
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
bin/
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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: <repo>/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 <service_id>_<name>.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: <service_id> <name> [# 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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "_")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user