refactor: extract all generators into independent TOOLS/ modules
Each generator is now a standalone Go module with its own go.mod: TOOLS/ ├── yaml-generator/ ← service_spec_gen (API → YAML) ├── resource-generator/ ← gen_v2 (YAML → Go resources) ├── docs-generator/ ← docs_template_gen_v2 (YAML → Markdown) ├── ops-generator/ ← ops_docs_gen (ops documentation) └── bin/ ← pre-built binaries All scripts updated to use TOOLS/bin/* binaries. Removed old universal_rebuild/tools/ and universal_rebuild/bin/.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
module docs-generator
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ops_docs_gen: builds per-service operations docs from resources_ops_yaml.
|
||||
//
|
||||
// Env:
|
||||
// - NUBES_OPS_YAML_DIR (default: <repo>/resources_ops_yaml)
|
||||
// - NUBES_OPS_DOCS_DIR (default: <repo>/../docs/30_registry/resources/operations)
|
||||
|
||||
func main() {
|
||||
repoRoot, err := findRepoRoot()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
yamlDir := strings.TrimSpace(os.Getenv("NUBES_OPS_YAML_DIR"))
|
||||
if yamlDir == "" {
|
||||
yamlDir = filepath.Join(repoRoot, "resources_ops_yaml")
|
||||
}
|
||||
|
||||
docsDir := strings.TrimSpace(os.Getenv("NUBES_OPS_DOCS_DIR"))
|
||||
if docsDir == "" {
|
||||
root := filepath.Dir(repoRoot)
|
||||
docsDir = filepath.Join(root, "docs", "30_registry", "resources", "operations")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
entries := []docEntry{}
|
||||
|
||||
err = filepath.WalkDir(yamlDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var spec ServiceOpsSpec
|
||||
if err := yaml.Unmarshal(b, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.ServiceID == 0 || spec.Name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("%d_%s.md", spec.ServiceID, sanitizeName(spec.Name))
|
||||
outPath := filepath.Join(docsDir, fileName)
|
||||
if err := writeServiceDoc(outPath, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries = append(entries, docEntry{
|
||||
ServiceID: spec.ServiceID,
|
||||
Name: spec.Name,
|
||||
File: fileName,
|
||||
Title: serviceTitle(spec),
|
||||
OpCount: len(spec.Operations),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].ServiceID < entries[j].ServiceID })
|
||||
if err := writeIndex(filepath.Join(docsDir, "index.md"), entries); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated %d operations docs in %s\n", len(entries), docsDir)
|
||||
}
|
||||
|
||||
type docEntry struct {
|
||||
ServiceID int
|
||||
Name string
|
||||
File string
|
||||
Title string
|
||||
OpCount int
|
||||
}
|
||||
|
||||
func writeServiceDoc(path string, spec ServiceOpsSpec) error {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("# Operations for nubes_%s\n\n", spec.Name))
|
||||
b.WriteString(fmt.Sprintf("Service ID: `%d`\n\n", spec.ServiceID))
|
||||
|
||||
if title := serviceTitle(spec); title != "" {
|
||||
b.WriteString(fmt.Sprintf("Service: %s\n\n", title))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.ServiceMan) != "" {
|
||||
b.WriteString("## UI description\n\n")
|
||||
b.WriteString(spec.ServiceMan)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("## Operations\n\n")
|
||||
b.WriteString("Non-CRUD operations are typically executed via an action resource.\n")
|
||||
b.WriteString("Domain operations (for example create_user, create_database) are best modeled as separate resources.\n\n")
|
||||
if len(spec.Operations) == 0 {
|
||||
b.WriteString("No operations found.\n")
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
for _, op := range spec.Operations {
|
||||
b.WriteString(fmt.Sprintf("- `%s` (id: %d)\n", op.Name, op.ID))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, op := range spec.Operations {
|
||||
b.WriteString(fmt.Sprintf("## Operation: %s\n\n", op.Name))
|
||||
b.WriteString(fmt.Sprintf("Operation ID: `%d`\n\n", op.ID))
|
||||
if strings.TrimSpace(op.Man) != "" {
|
||||
b.WriteString(op.Man)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if len(op.Params) == 0 {
|
||||
b.WriteString("No parameters.\n\n")
|
||||
continue
|
||||
}
|
||||
|
||||
b.WriteString("| Code | Type | Required | Default | ID | RefSvcId | ValueList | Func | Regex | Min | Max | Sensitive | DependsOn |\n")
|
||||
b.WriteString("|---|---|---|---|---|---|---|---|---|---|---|---|---|\n")
|
||||
for _, p := range op.Params {
|
||||
b.WriteString(fmt.Sprintf("| `%s` | `%s` | `%t` | `%s` | `%d` | `%s` | `%s` | `%s` | `%s` | `%s` | `%s` | `%t` | `%s` |\n",
|
||||
esc(p.Code),
|
||||
esc(p.DataType),
|
||||
p.Required,
|
||||
esc(formatValue(p.Default)),
|
||||
p.ID,
|
||||
esc(formatRef(p.RefSvcId)),
|
||||
esc(formatList(p.ValueList)),
|
||||
esc(p.Func),
|
||||
esc(p.Regex),
|
||||
esc(formatValue(p.MinValue)),
|
||||
esc(formatValue(p.MaxValue)),
|
||||
p.IsSensitive,
|
||||
esc(formatValue(p.DependsOn)),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeIndex(path string, entries []docEntry) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Operations by service\n\n")
|
||||
b.WriteString("Auto-generated list of available operations per service.\n\n")
|
||||
b.WriteString("Note: non-CRUD operations should be invoked via an action resource,\n")
|
||||
b.WriteString("and domain operations are best represented as dedicated resources.\n\n")
|
||||
for _, e := range entries {
|
||||
b.WriteString(fmt.Sprintf("- %d - nubes_%s (%d ops): [%s](%s)\n", e.ServiceID, e.Name, e.OpCount, e.Title, e.File))
|
||||
}
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
name = strings.ReplaceAll(name, " ", "_")
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, ":", "_")
|
||||
name = strings.ReplaceAll(name, "-", "_")
|
||||
return name
|
||||
}
|
||||
|
||||
func serviceTitle(spec ServiceOpsSpec) string {
|
||||
if strings.TrimSpace(spec.ServiceDisplayName) != "" {
|
||||
return spec.ServiceDisplayName
|
||||
}
|
||||
if strings.TrimSpace(spec.ServiceShortName) != "" {
|
||||
return spec.ServiceShortName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatList(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(items, ",")
|
||||
}
|
||||
|
||||
func formatRef(value *int) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", *value)
|
||||
}
|
||||
|
||||
func formatValue(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := value.(type) {
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func esc(value string) string {
|
||||
return strings.ReplaceAll(value, "|", "\\|")
|
||||
}
|
||||
|
||||
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, "resources_yaml")
|
||||
info, err := os.Stat(candidate)
|
||||
if err == nil && info.IsDir() {
|
||||
return current, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return "", fmt.Errorf("failed to locate universal_rebuild repo root")
|
||||
}
|
||||
|
||||
// ===== spec =====
|
||||
|
||||
type ServiceOpsSpec 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"`
|
||||
Operations []OperationSpec `yaml:"operations"`
|
||||
}
|
||||
|
||||
type OperationSpec struct {
|
||||
Name string `yaml:"name"`
|
||||
ID int `yaml:"id"`
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module ops-generator
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,5 @@
|
||||
module resource-generator
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package helpers — конвертация имён, форматирование, plan-модификаторы.
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"resource-generator/types"
|
||||
)
|
||||
|
||||
// ToCamel конвертирует snake_case → CamelCase.
|
||||
func ToCamel(s string) string {
|
||||
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' })
|
||||
for i, p := range parts {
|
||||
if len(p) == 0 {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
out := strings.Join(parts, "")
|
||||
return EnsureGoIdent(out)
|
||||
}
|
||||
|
||||
// ToSnake конвертирует CamelCase → snake_case.
|
||||
func ToSnake(s string) string {
|
||||
var out []rune
|
||||
for i, r := range s {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
out = append(out, '_')
|
||||
}
|
||||
out = append(out, rune(strings.ToLower(string(r))[0]))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ParamType возвращает Go-тип для параметра (types.Bool/Int64/String).
|
||||
func ParamType(p types.Param) string {
|
||||
switch strings.ToLower(p.Type) {
|
||||
case "bool":
|
||||
return "types.Bool"
|
||||
case "int", "int64", "number":
|
||||
return "types.Int64"
|
||||
default:
|
||||
return "types.String"
|
||||
}
|
||||
}
|
||||
|
||||
// ParamDefaultExpr возвращает выражение значения по умолчанию.
|
||||
func ParamDefaultExpr(p types.Param) string {
|
||||
if p.Default == "" {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(p.Type) {
|
||||
case "bool":
|
||||
val := strings.ToLower(p.Default)
|
||||
return fmt.Sprintf("booldefault.StaticBool(%s)", val)
|
||||
case "int", "int64", "number":
|
||||
return fmt.Sprintf("int64default.StaticInt64(%s)", p.Default)
|
||||
default:
|
||||
return fmt.Sprintf("stringdefault.StaticString(%q)", p.Default)
|
||||
}
|
||||
}
|
||||
|
||||
// ParamFormat возвращает вызов Format* для форматирования значения параметра.
|
||||
func ParamFormat(p types.Param, varName string) string {
|
||||
switch strings.ToLower(p.Type) {
|
||||
case "bool":
|
||||
return fmt.Sprintf("resources_core.FormatBool(%s)", varName)
|
||||
case "int", "int64", "number":
|
||||
return fmt.Sprintf("resources_core.FormatInt64(%s)", varName)
|
||||
default:
|
||||
return fmt.Sprintf("resources_core.FormatString(%s)", varName)
|
||||
}
|
||||
}
|
||||
|
||||
// ParamParse возвращает имя Parse*-функции для параметра.
|
||||
func ParamParse(p types.Param) string {
|
||||
switch strings.ToLower(p.Type) {
|
||||
case "bool":
|
||||
return "resources_core.ParseBool"
|
||||
case "int", "int64", "number":
|
||||
return "resources_core.ParseInt64"
|
||||
default:
|
||||
return "resources_core.ParseString"
|
||||
}
|
||||
}
|
||||
|
||||
// ParamDescription формирует MarkdownDescription для параметра.
|
||||
func ParamDescription(p types.Param) string {
|
||||
var desc string
|
||||
if strings.TrimSpace(p.Descr) != "" {
|
||||
desc = strings.TrimSpace(p.Descr)
|
||||
} else if strings.TrimSpace(p.Man) != "" {
|
||||
desc = strings.TrimSpace(p.Man)
|
||||
} else if p.RefSvcId > 0 {
|
||||
desc = fmt.Sprintf("UI display_name for service_id=%d (mapped to UUID in core)", p.RefSvcId)
|
||||
}
|
||||
if p.CreateOnly {
|
||||
if desc == "" {
|
||||
desc = "Не изменяется после создания."
|
||||
} else {
|
||||
desc = desc + " Не изменяется после создания."
|
||||
}
|
||||
}
|
||||
if p.ForceNew {
|
||||
if desc == "" {
|
||||
desc = "Изменение требует пересоздания."
|
||||
} else {
|
||||
desc = desc + " Изменение требует пересоздания."
|
||||
}
|
||||
}
|
||||
if desc == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%q", desc)
|
||||
}
|
||||
|
||||
// OutputParamType возвращает Go-тип для выходного параметра (types.Map/List/String).
|
||||
func OutputParamType(p types.OutputParam) string {
|
||||
switch strings.ToLower(p.Type) {
|
||||
case "map":
|
||||
return "types.Map"
|
||||
case "list":
|
||||
return "types.List"
|
||||
default:
|
||||
return "types.String"
|
||||
}
|
||||
}
|
||||
|
||||
// OutputParamIsMap возвращает true, если выходной параметр — map.
|
||||
func OutputParamIsMap(p types.OutputParam) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(p.Type), "map")
|
||||
}
|
||||
|
||||
// OutputParamIsList возвращает true, если выходной параметр — list.
|
||||
func OutputParamIsList(p types.OutputParam) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(p.Type), "list")
|
||||
}
|
||||
|
||||
// OutputParamSensitive возвращает true, если параметр секретный.
|
||||
func OutputParamSensitive(p types.OutputParam) bool {
|
||||
return p.Sensitive
|
||||
}
|
||||
|
||||
// EnsureGoIdent проверяет, что строка начинается с буквы или _.
|
||||
func EnsureGoIdent(s string) string {
|
||||
if s == "" {
|
||||
return "R"
|
||||
}
|
||||
first := rune(s[0])
|
||||
if unicode.IsLetter(first) || first == '_' {
|
||||
return s
|
||||
}
|
||||
return "R" + s
|
||||
}
|
||||
|
||||
// FormatSourceOrWarn форматирует Go-код. При ошибке пишет warning в stderr.
|
||||
func FormatSourceOrWarn(filePath string, src []byte) []byte {
|
||||
formatted, err := format.Source(src)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: gofmt failed for %s: %v — writing unformatted code\n", filePath, err)
|
||||
return src
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
// PlanModifierType возвращает суффикс типа план-модификатора (Bool/Int64/String).
|
||||
func PlanModifierType(p types.Param) string {
|
||||
switch strings.ToLower(strings.TrimSpace(p.Type)) {
|
||||
case "bool":
|
||||
return "Bool"
|
||||
case "int", "int64", "number":
|
||||
return "Int64"
|
||||
default:
|
||||
return "String"
|
||||
}
|
||||
}
|
||||
|
||||
// PlanModifierExpr возвращает выражение план-модификатора RequiresReplace.
|
||||
func PlanModifierExpr(p types.Param) string {
|
||||
switch strings.ToLower(strings.TrimSpace(p.Type)) {
|
||||
case "bool":
|
||||
return "boolplanmodifier.RequiresReplace()"
|
||||
case "int", "int64", "number":
|
||||
return "int64planmodifier.RequiresReplace()"
|
||||
default:
|
||||
return "stringplanmodifier.RequiresReplace()"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// Package loader — загрузка YAML-спеков и построение ресурсов.
|
||||
package loader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"resource-generator/params"
|
||||
"resource-generator/types"
|
||||
)
|
||||
|
||||
// LoadSpecs загружает все YAML-спеки из директории и строит GenResource/GenSubresource/GenAction.
|
||||
func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, error) {
|
||||
var services []types.GenResource
|
||||
var subs []types.GenSubresource
|
||||
var actions []types.GenAction
|
||||
domainServiceIDsSet := map[int]struct{}{}
|
||||
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var spec types.ServiceSpec
|
||||
if err := yaml.Unmarshal(b, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях.
|
||||
if err := ValidateSpec(path, &spec); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
createParams := []types.Param{}
|
||||
modifyParams := []types.Param{}
|
||||
supportsSuspendDestroy := false
|
||||
for _, op := range spec.Operations {
|
||||
if op.Kind != "instance" {
|
||||
continue
|
||||
}
|
||||
switch op.Action {
|
||||
case "create":
|
||||
createParams = ConvertParams(op.Params)
|
||||
case "modify":
|
||||
modifyParams = ConvertParams(op.Params)
|
||||
case "suspend":
|
||||
supportsSuspendDestroy = true
|
||||
}
|
||||
}
|
||||
schemaParams := params.Merge(createParams, modifyParams)
|
||||
createParams = params.AlignParamTypes(createParams, schemaParams)
|
||||
modifyParams = params.AlignParamTypes(modifyParams, schemaParams)
|
||||
createOnly := params.ComputeCreateOnly(createParams, modifyParams)
|
||||
schemaParams = params.MarkCreateOnly(schemaParams, createOnly)
|
||||
hasDomainParam := false
|
||||
for _, param := range schemaParams {
|
||||
if strings.EqualFold(strings.TrimSpace(param.Code), "domain") {
|
||||
hasDomainParam = true
|
||||
domainServiceIDsSet[spec.ServiceID] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
adoptExistingOnCreate := false
|
||||
|
||||
suspendOnDestroy := true
|
||||
if spec.Lifecycle.SuspendOnDestroyDefault != nil {
|
||||
suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault
|
||||
}
|
||||
|
||||
gr := types.GenResource{
|
||||
Name: spec.Name,
|
||||
ServiceID: spec.ServiceID,
|
||||
CreateParams: createParams,
|
||||
ModifyParams: modifyParams,
|
||||
SchemaParams: schemaParams,
|
||||
CreateOnlyParams: params.FilterCreateOnly(schemaParams),
|
||||
CreateOnlyRequiredParams: params.FilterCreateOnlyRequired(schemaParams),
|
||||
OutputParams: NormalizeOutputParams(spec.Outputs.Params),
|
||||
HasRefSvcParams: HasRefSvcParams(schemaParams),
|
||||
SupportsSuspendDestroy: supportsSuspendDestroy,
|
||||
SuspendOnDestroy: suspendOnDestroy,
|
||||
AdoptExistingOnCreate: adoptExistingOnCreate,
|
||||
HasDomainParam: hasDomainParam,
|
||||
}
|
||||
params.Analyze(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams)
|
||||
gr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(gr.SchemaParams)
|
||||
gr.NeedsStringsImport = params.HasRestoreCasingParams(gr.SchemaParams) || params.AnalyzeNeedsStrings(gr.ModifyParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyRequiredParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyParams)
|
||||
|
||||
// --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) ---
|
||||
for _, op := range spec.Operations {
|
||||
if op.Kind != "action" {
|
||||
continue
|
||||
}
|
||||
switch op.Action {
|
||||
case "redeploy":
|
||||
gr.HasRedeploy = true
|
||||
case "restart", "recovery", "reconcile":
|
||||
// Исключены
|
||||
default:
|
||||
act := types.GenAction{
|
||||
ServiceName: spec.Name,
|
||||
ServiceID: spec.ServiceID,
|
||||
ActionName: op.Action,
|
||||
OperationName: op.Name,
|
||||
Params: ConvertParams(op.Params),
|
||||
}
|
||||
act.SchemaParams = act.Params
|
||||
params.Analyze(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams)
|
||||
act.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(act.SchemaParams)
|
||||
act.NeedsStringsImport = params.AnalyzeNeedsStrings(act.SchemaParams)
|
||||
actions = append(actions, act)
|
||||
}
|
||||
}
|
||||
|
||||
services = append(services, gr)
|
||||
|
||||
srByName := map[string]*types.GenSubresource{}
|
||||
for _, op := range spec.Operations {
|
||||
if op.Kind != "subresource" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(op.Subresource)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
sr := srByName[name]
|
||||
if sr == nil {
|
||||
sr = &types.GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name}
|
||||
srByName[name] = sr
|
||||
}
|
||||
switch op.Action {
|
||||
case "create":
|
||||
sr.CreateOpName = op.Name
|
||||
sr.CreateParams = ConvertParams(op.Params)
|
||||
case "modify":
|
||||
sr.ModifyOpName = op.Name
|
||||
sr.ModifyParams = ConvertParams(op.Params)
|
||||
case "delete":
|
||||
sr.DeleteOpName = op.Name
|
||||
sr.DeleteParams = ConvertParams(op.Params)
|
||||
}
|
||||
}
|
||||
for _, sr := range srByName {
|
||||
sr.SchemaParams = params.Merge(sr.CreateParams, sr.ModifyParams, sr.DeleteParams)
|
||||
sr.CreateParams = params.AlignParamTypes(sr.CreateParams, sr.SchemaParams)
|
||||
sr.ModifyParams = params.AlignParamTypes(sr.ModifyParams, sr.SchemaParams)
|
||||
sr.DeleteParams = params.AlignParamTypes(sr.DeleteParams, sr.SchemaParams)
|
||||
createOnly := params.ComputeCreateOnly(sr.CreateParams, sr.ModifyParams)
|
||||
sr.SchemaParams = params.MarkCreateOnly(sr.SchemaParams, createOnly)
|
||||
forceNewCodes := params.BuildSubresourceForceNewCodes(sr, createOnly)
|
||||
sr.SchemaParams = params.MarkForceNew(sr.SchemaParams, forceNewCodes)
|
||||
sr.IdentityParams = params.FilterForceNew(sr.SchemaParams)
|
||||
if len(sr.IdentityParams) == 0 {
|
||||
sr.IdentityParams = sr.SchemaParams
|
||||
}
|
||||
sr.HasRefSvcParams = HasRefSvcParams(sr.SchemaParams)
|
||||
params.Analyze(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams)
|
||||
params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams)
|
||||
sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams)
|
||||
sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams)
|
||||
subs = append(subs, *sr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if walkErr != nil {
|
||||
return nil, nil, nil, walkErr
|
||||
}
|
||||
|
||||
domainServiceIDs := make([]int, 0, len(domainServiceIDsSet))
|
||||
for serviceID := range domainServiceIDsSet {
|
||||
domainServiceIDs = append(domainServiceIDs, serviceID)
|
||||
}
|
||||
sort.Ints(domainServiceIDs)
|
||||
for idx := range services {
|
||||
if len(domainServiceIDs) == 0 {
|
||||
services[idx].DomainServiceIDs = nil
|
||||
continue
|
||||
}
|
||||
services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...)
|
||||
}
|
||||
|
||||
sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
|
||||
sort.Slice(subs, func(i, j int) bool {
|
||||
if subs[i].ServiceName == subs[j].ServiceName {
|
||||
return subs[i].SubName < subs[j].SubName
|
||||
}
|
||||
return subs[i].ServiceName < subs[j].ServiceName
|
||||
})
|
||||
sort.Slice(actions, func(i, j int) bool {
|
||||
if actions[i].ServiceName == actions[j].ServiceName {
|
||||
return actions[i].ActionName < actions[j].ActionName
|
||||
}
|
||||
return actions[i].ServiceName < actions[j].ServiceName
|
||||
})
|
||||
|
||||
return services, subs, actions, nil
|
||||
}
|
||||
|
||||
// ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов.
|
||||
func ConvertParams(params []types.ParamSpec) []types.Param {
|
||||
out := make([]types.Param, 0, len(params))
|
||||
for _, p := range params {
|
||||
typeName := NormalizeParamType(p.DataType)
|
||||
defVal := NormalizeDefault(p.Default)
|
||||
ref := 0
|
||||
if p.RefSvcId != nil {
|
||||
ref = *p.RefSvcId
|
||||
}
|
||||
out = append(out, types.Param{
|
||||
ID: p.ID,
|
||||
Code: p.Code,
|
||||
Type: typeName,
|
||||
Required: p.Required,
|
||||
Default: defVal,
|
||||
RefSvcId: ref,
|
||||
Descr: strings.TrimSpace(p.Descr),
|
||||
Man: strings.TrimSpace(p.Man),
|
||||
Sensitive: p.Sensitive,
|
||||
IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeParamType нормализует строку data_type → bool | int64 | string.
|
||||
func NormalizeParamType(value string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
if strings.Contains(v, "bool") {
|
||||
return "bool"
|
||||
}
|
||||
if strings.Contains(v, "int") || strings.Contains(v, "number") {
|
||||
return "int64"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// NormalizeDefault нормализует значение по умолчанию в строку.
|
||||
func NormalizeDefault(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeOutputParams нормализует выходные параметры.
|
||||
func NormalizeOutputParams(params []types.OutputParam) []types.OutputParam {
|
||||
if len(params) == 0 {
|
||||
return []types.OutputParam{}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// HasRefSvcParams проверяет наличие ref_svc-параметров.
|
||||
func HasRefSvcParams(params []types.Param) bool {
|
||||
for _, p := range params {
|
||||
if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KnownKinds — допустимые значения Kind операций.
|
||||
var KnownKinds = map[string]bool{
|
||||
"instance": true,
|
||||
"subresource": true,
|
||||
"action": true,
|
||||
}
|
||||
|
||||
// ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds.
|
||||
// P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования.
|
||||
func ValidateSpec(path string, spec *types.ServiceSpec) error {
|
||||
if spec.Name == "" {
|
||||
return fmt.Errorf("missing required field: name")
|
||||
}
|
||||
if spec.ServiceID <= 0 {
|
||||
return fmt.Errorf("missing required field: service_id")
|
||||
}
|
||||
for i, op := range spec.Operations {
|
||||
if op.Kind == "" {
|
||||
return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name)
|
||||
}
|
||||
if !KnownKinds[op.Kind] {
|
||||
return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action)", i, op.Name, op.Kind)
|
||||
}
|
||||
if op.Action == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind)
|
||||
}
|
||||
if op.Kind == "subresource" && op.Subresource == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// generate_resources_v2 — главный генератор Terraform-провайдера Nubes.
|
||||
//
|
||||
// ⛔ ПОДЧИНЯЕТСЯ devops/ARCHITECTURE.md — все правила генерации прописаны там.
|
||||
//
|
||||
// Пайплайн: YAML-спеки сервисов → Go-ресурсы + registry.go.
|
||||
//
|
||||
// На вход: resources_yaml/*.yaml (сгенерированы service_spec_gen из API).
|
||||
// На выход: internal/resources_gen/*.go (CRUD-ресурсы, subresource'ы, registry).
|
||||
//
|
||||
// Три вида ресурсов:
|
||||
// - instance → nubes_{service} (основной CRUD инстанса)
|
||||
// - subresource → nubes_{service}_{sub} (пользователи, базы, ...)
|
||||
// - action → ТОЛЬКО redeploy встроен как git_revision.
|
||||
// restart/recovery/reconcile исключены (ARCHITECTURE.md).
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"resource-generator/loader"
|
||||
"resource-generator/writers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resourcesDir := strings.TrimSpace(os.Getenv("NUBES_RESOURCES_DIR"))
|
||||
if resourcesDir == "" {
|
||||
resourcesDir = filepath.Join(root, "resources_yaml")
|
||||
}
|
||||
outDir := strings.TrimSpace(os.Getenv("NUBES_RESOURCES_GEN_DIR"))
|
||||
if outDir == "" {
|
||||
outDir = filepath.Join(root, "internal", "resources_gen")
|
||||
}
|
||||
|
||||
instanceResources, subresources, actions, err := loader.LoadSpecs(resourcesDir)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, svc := range instanceResources {
|
||||
if err := writers.WriteInstanceResource(outDir, svc); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
for _, sr := range subresources {
|
||||
if err := writers.WriteSubresource(outDir, sr); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
for _, act := range actions {
|
||||
if err := writers.WriteActionResource(outDir, act); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Package params — слияние, фильтрация, анализ параметров и ForceNew.
|
||||
package params
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"resource-generator/types"
|
||||
)
|
||||
|
||||
// Merge объединяет несколько наборов параметров, убирая дубликаты по Code.
|
||||
func Merge(sets ...[]types.Param) []types.Param {
|
||||
seen := map[string]types.Param{}
|
||||
for _, s := range sets {
|
||||
for _, p := range s {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; !ok {
|
||||
seen[key] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]types.Param, 0, len(seen))
|
||||
for _, p := range seen {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
|
||||
return out
|
||||
}
|
||||
|
||||
// AlignParamTypes выравнивает типы параметров по схеме.
|
||||
func AlignParamTypes(params []types.Param, schema []types.Param) []types.Param {
|
||||
if len(params) == 0 || len(schema) == 0 {
|
||||
return params
|
||||
}
|
||||
codeType := make(map[string]string, len(schema))
|
||||
for _, p := range schema {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(p.Type) != "" {
|
||||
codeType[key] = p.Type
|
||||
}
|
||||
}
|
||||
if len(codeType) == 0 {
|
||||
return params
|
||||
}
|
||||
|
||||
for i, p := range params {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if t, ok := codeType[key]; ok && strings.TrimSpace(t) != "" {
|
||||
params[i].Type = t
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// ComputeCreateOnly вычисляет набор полей, которые есть только в create (не в modify).
|
||||
func ComputeCreateOnly(createParams []types.Param, modifyParams []types.Param) map[string]struct{} {
|
||||
modifyCodes := make(map[string]struct{}, len(modifyParams))
|
||||
for _, p := range modifyParams {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
modifyCodes[key] = struct{}{}
|
||||
}
|
||||
|
||||
createOnly := make(map[string]struct{})
|
||||
for _, p := range createParams {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := modifyCodes[key]; !ok {
|
||||
createOnly[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return createOnly
|
||||
}
|
||||
|
||||
// MarkCreateOnly проставляет CreateOnly=true для полей, которых нет в modify.
|
||||
func MarkCreateOnly(schema []types.Param, createOnly map[string]struct{}) []types.Param {
|
||||
if len(schema) == 0 || len(createOnly) == 0 {
|
||||
return schema
|
||||
}
|
||||
|
||||
for i, p := range schema {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := createOnly[key]; ok {
|
||||
schema[i].CreateOnly = true
|
||||
}
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
// FilterCreateOnly возвращает только CreateOnly-параметры.
|
||||
func FilterCreateOnly(schema []types.Param) []types.Param {
|
||||
if len(schema) == 0 {
|
||||
return []types.Param{}
|
||||
}
|
||||
|
||||
result := make([]types.Param, 0, len(schema))
|
||||
for _, p := range schema {
|
||||
if p.CreateOnly {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterCreateOnlyRequired возвращает обязательные CreateOnly-параметры.
|
||||
func FilterCreateOnlyRequired(schema []types.Param) []types.Param {
|
||||
if len(schema) == 0 {
|
||||
return []types.Param{}
|
||||
}
|
||||
|
||||
result := make([]types.Param, 0, len(schema))
|
||||
for _, p := range schema {
|
||||
if p.CreateOnly && p.Required {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Analyze анализирует типы параметров и проставляет флаги uses/needs.
|
||||
func Analyze(usesBool *bool, usesInt64 *bool, usesString *bool, hasDefaults *bool, needsBoolDefault *bool, needsInt64Default *bool, needsStringDefault *bool, params []types.Param) {
|
||||
for _, p := range params {
|
||||
switch strings.ToLower(p.Type) {
|
||||
case "bool":
|
||||
*usesBool = true
|
||||
if p.Default != "" {
|
||||
*needsBoolDefault = true
|
||||
*hasDefaults = true
|
||||
}
|
||||
case "int", "int64", "number":
|
||||
*usesInt64 = true
|
||||
if p.Default != "" {
|
||||
*needsInt64Default = true
|
||||
*hasDefaults = true
|
||||
}
|
||||
default:
|
||||
*usesString = true
|
||||
if p.Default != "" {
|
||||
*needsStringDefault = true
|
||||
*hasDefaults = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSubresourceForceNewCodes строит карту параметров, требующих ForceNew.
|
||||
func BuildSubresourceForceNewCodes(sr *types.GenSubresource, createOnly map[string]struct{}) map[string]struct{} {
|
||||
forceNew := map[string]struct{}{}
|
||||
if sr.ModifyOpName == "" {
|
||||
for _, p := range sr.SchemaParams {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key != "" {
|
||||
forceNew[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
return forceNew
|
||||
}
|
||||
for key := range createOnly {
|
||||
forceNew[key] = struct{}{}
|
||||
}
|
||||
for _, p := range sr.DeleteParams {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key != "" {
|
||||
forceNew[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
return forceNew
|
||||
}
|
||||
|
||||
// MarkForceNew проставляет ForceNew=true для указанных параметров.
|
||||
func MarkForceNew(params []types.Param, forceNew map[string]struct{}) []types.Param {
|
||||
if len(params) == 0 || len(forceNew) == 0 {
|
||||
return params
|
||||
}
|
||||
for i, p := range params {
|
||||
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := forceNew[key]; ok {
|
||||
params[i].ForceNew = true
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// FilterForceNew возвращает только ForceNew-параметры.
|
||||
func FilterForceNew(params []types.Param) []types.Param {
|
||||
result := []types.Param{}
|
||||
for _, p := range params {
|
||||
if p.ForceNew {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AnalyzeJsonPlanMod возвращает true, если хотя бы один параметр — JSON.
|
||||
func AnalyzeJsonPlanMod(params []types.Param) bool {
|
||||
for _, p := range params {
|
||||
if p.IsJson {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasRestoreCasingParams возвращает true, если есть ref_svc-параметры (кроме S3).
|
||||
func HasRestoreCasingParams(params []types.Param) bool {
|
||||
for _, p := range params {
|
||||
if p.RefSvcId > 0 && p.RefSvcId != 12 && strings.EqualFold(strings.TrimSpace(p.Type), "string") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AnalyzeNeedsStrings возвращает true, если есть строковые параметры.
|
||||
func AnalyzeNeedsStrings(params []types.Param) bool {
|
||||
for _, p := range params {
|
||||
t := strings.ToLower(strings.TrimSpace(p.Type))
|
||||
if t != "bool" && t != "int" && t != "int64" && t != "number" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AnalyzePlanModifiers анализирует ForceNew-параметры для добавления planmodifier-импортов.
|
||||
func AnalyzePlanModifiers(needsBool *bool, needsInt64 *bool, needsString *bool, params []types.Param) {
|
||||
for _, p := range params {
|
||||
if !p.ForceNew {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(p.Type)) {
|
||||
case "bool":
|
||||
*needsBool = true
|
||||
case "int", "int64", "number":
|
||||
*needsInt64 = true
|
||||
default:
|
||||
*needsString = true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
// Package types — структуры данных для генератора Terraform-провайдера.
|
||||
package types
|
||||
|
||||
// ─── Входные структуры (из YAML) ───────────────────────────────────────────
|
||||
|
||||
// ParamSpec — один параметр операции как он описан в YAML.
|
||||
// Соответствует cfsParam из API Nubes.
|
||||
type ParamSpec struct {
|
||||
ID int `yaml:"id"` // svcOperationCfsParamId
|
||||
Code string `yaml:"code"` // имя параметра (camelCase)
|
||||
DataType string `yaml:"data_type,omitempty"` // string | integer > 0 | boolean | map-fixed | uuid | json | ...
|
||||
Required bool `yaml:"required"` // обязательный?
|
||||
Default interface{} `yaml:"default,omitempty"` // значение по умолчанию (строка или число)
|
||||
RefSvcId *int `yaml:"ref_svc_id,omitempty"` // ссылка на другой сервис (UUID-параметр)
|
||||
Descr string `yaml:"descr,omitempty"` // описание
|
||||
Man string `yaml:"man,omitempty"` // MAN-руководство
|
||||
Sensitive bool `yaml:"is_sensitive,omitempty"` // секретный параметр?
|
||||
}
|
||||
|
||||
// OutputParam — выходной параметр сервиса (state_params, vault_secrets, ...).
|
||||
type OutputParam struct {
|
||||
Code string `yaml:"code"` // код: state_params, state_out, vault_url, ...
|
||||
Type string `yaml:"type"` // map | string | list
|
||||
Sensitive bool `yaml:"sensitive,omitempty"` // vault_secrets = true
|
||||
}
|
||||
|
||||
// OperationSpec — одна операция сервиса (create, modify, delete_user, ...).
|
||||
type OperationSpec struct {
|
||||
Name string `yaml:"name"` // имя операции (create, modify, ...)
|
||||
ID int `yaml:"id"` // svcOperationId
|
||||
Kind string `yaml:"kind"` // instance | subresource | action
|
||||
Action string `yaml:"action"` // create | modify | delete | suspend | redeploy | ...
|
||||
Subresource string `yaml:"subresource,omitempty"` // для subresource: user, database, topic, ...
|
||||
Man string `yaml:"man,omitempty"` // MAN-руководство операции
|
||||
Params []ParamSpec `yaml:"params"` // параметры операции
|
||||
}
|
||||
|
||||
// ServiceSpec — полная YAML-спецификация одного сервиса.
|
||||
type ServiceSpec struct {
|
||||
Name string `yaml:"name"` // snake_case имя (postgres, s3bucket, ...)
|
||||
ServiceID int `yaml:"service_id"` // числовой ID сервиса в Nubes
|
||||
Outputs struct {
|
||||
Params []OutputParam `yaml:"params"`
|
||||
} `yaml:"outputs"`
|
||||
Lifecycle struct {
|
||||
SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"`
|
||||
AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"`
|
||||
} `yaml:"lifecycle"`
|
||||
Operations []OperationSpec `yaml:"operations"`
|
||||
}
|
||||
|
||||
// Param — нормализованный параметр (после конвертации из ParamSpec).
|
||||
type Param struct {
|
||||
ID int
|
||||
Code string
|
||||
Type string
|
||||
Required bool
|
||||
Default string
|
||||
RefSvcId int
|
||||
Descr string
|
||||
Man string
|
||||
Sensitive bool
|
||||
CreateOnly bool
|
||||
ForceNew bool
|
||||
// IsJson — атрибут является JSON-строкой (data_type: json в YAML-спеке).
|
||||
// При IsJson=true в схему добавляется JsonNormalize план-модификатор,
|
||||
// чтобы план и API-ответ (компактный JSON) всегда совпадали.
|
||||
IsJson bool
|
||||
}
|
||||
|
||||
// GenResource — ресурс инстанса (nubes_{service}).
|
||||
type GenResource struct {
|
||||
Name string
|
||||
ServiceID int
|
||||
CreateParams []Param
|
||||
ModifyParams []Param
|
||||
SchemaParams []Param
|
||||
CreateOnlyParams []Param
|
||||
CreateOnlyRequiredParams []Param
|
||||
OutputParams []OutputParam
|
||||
HasRefSvcParams bool
|
||||
SupportsSuspendDestroy bool
|
||||
SuspendOnDestroy bool
|
||||
AdoptExistingOnCreate bool
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
HasDomainParam bool
|
||||
DomainServiceIDs []int
|
||||
// HasRedeploy: сервис поддерживает redeploy (пересборка из git).
|
||||
// Если true — в основной ресурс добавляется поле git_revision.
|
||||
// При изменении git_revision вызывается redeploy вместо modify.
|
||||
// Правило из ARCHITECTURE.md: только redeploy включается как inline action.
|
||||
// restart, recovery, reconcile — исключены (ручные операции, только UI).
|
||||
HasRedeploy bool
|
||||
// NeedsJsonPlanMod: хотя бы один атрибут имеет data_type: json.
|
||||
// Управляет добавлением импорта planmodifier в сгенерированный файл.
|
||||
NeedsJsonPlanMod bool
|
||||
// NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update).
|
||||
NeedsStringsImport bool
|
||||
}
|
||||
|
||||
// GenSubresource — подресурс (nubes_{service}_{sub}).
|
||||
type GenSubresource struct {
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
SubName string
|
||||
CreateOpName string
|
||||
ModifyOpName string
|
||||
DeleteOpName string
|
||||
CreateParams []Param
|
||||
ModifyParams []Param
|
||||
DeleteParams []Param
|
||||
SchemaParams []Param
|
||||
IdentityParams []Param
|
||||
HasRefSvcParams bool
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsBoolPlanMod bool
|
||||
NeedsInt64PlanMod bool
|
||||
NeedsStringPlanMod bool
|
||||
NeedsJsonPlanMod bool
|
||||
NeedsStringsImport bool
|
||||
}
|
||||
|
||||
// GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource).
|
||||
type GenAction struct {
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
ActionName string
|
||||
OperationName string
|
||||
Params []Param
|
||||
SchemaParams []Param
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsJsonPlanMod bool
|
||||
NeedsStringsImport bool
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package writers — генерация Go-файлов ресурсов и registry.go.
|
||||
package writers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
"resource-generator/helpers"
|
||||
"resource-generator/templates"
|
||||
"resource-generator/types"
|
||||
)
|
||||
|
||||
// WriteInstanceResource генерирует nubes_{service} (CRUD инстанса).
|
||||
func WriteInstanceResource(outDir string, svc types.GenResource) error {
|
||||
fileName := fmt.Sprintf("%d_%s_resource.go", svc.ServiceID, svc.Name)
|
||||
filePath := filepath.Join(outDir, fileName)
|
||||
|
||||
tpl, err := template.New("resource").Funcs(template.FuncMap{
|
||||
"ToCamel": helpers.ToCamel,
|
||||
"ToSnake": helpers.ToSnake,
|
||||
"ParamType": helpers.ParamType,
|
||||
"ParamDefaultExpr": helpers.ParamDefaultExpr,
|
||||
"ParamFormat": helpers.ParamFormat,
|
||||
"ParamParse": helpers.ParamParse,
|
||||
"ParamDescription": helpers.ParamDescription,
|
||||
"OutputType": helpers.OutputParamType,
|
||||
"OutputIsMap": helpers.OutputParamIsMap,
|
||||
"OutputIsList": helpers.OutputParamIsList,
|
||||
"OutputSensitive": helpers.OutputParamSensitive,
|
||||
"bt": func() string { return "`" },
|
||||
}).Parse(templates.Instance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, svc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes())
|
||||
|
||||
return os.WriteFile(filePath, formatted, 0644)
|
||||
}
|
||||
|
||||
// WriteSubresource генерирует nubes_{service}_{sub} (пользователи, базы, ...).
|
||||
func WriteSubresource(outDir string, sr types.GenSubresource) error {
|
||||
name := fmt.Sprintf("%s_%s", sr.ServiceName, sr.SubName)
|
||||
fileName := fmt.Sprintf("%d_%s_resource.go", sr.ServiceID, name)
|
||||
filePath := filepath.Join(outDir, fileName)
|
||||
|
||||
tpl, err := template.New("subresource").Funcs(template.FuncMap{
|
||||
"ToCamel": helpers.ToCamel,
|
||||
"ToSnake": helpers.ToSnake,
|
||||
"ParamType": helpers.ParamType,
|
||||
"ParamDefaultExpr": helpers.ParamDefaultExpr,
|
||||
"ParamFormat": helpers.ParamFormat,
|
||||
"ParamParse": helpers.ParamParse,
|
||||
"ParamDescription": helpers.ParamDescription,
|
||||
"PlanModifierType": helpers.PlanModifierType,
|
||||
"PlanModifierExpr": helpers.PlanModifierExpr,
|
||||
"bt": func() string { return "`" },
|
||||
}).Parse(templates.Subresource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, sr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes())
|
||||
|
||||
return os.WriteFile(filePath, formatted, 0644)
|
||||
}
|
||||
|
||||
// WriteActionResource генерирует action-ресурс (почти не используется).
|
||||
func WriteActionResource(outDir string, act types.GenAction) error {
|
||||
name := fmt.Sprintf("%s_%s", act.ServiceName, act.ActionName)
|
||||
fileName := fmt.Sprintf("%d_%s_action.go", act.ServiceID, name)
|
||||
filePath := filepath.Join(outDir, fileName)
|
||||
|
||||
tpl, err := template.New("action").Funcs(template.FuncMap{
|
||||
"ToCamel": helpers.ToCamel,
|
||||
"ToSnake": helpers.ToSnake,
|
||||
"ParamType": helpers.ParamType,
|
||||
"ParamDefaultExpr": helpers.ParamDefaultExpr,
|
||||
"ParamFormat": helpers.ParamFormat,
|
||||
"ParamParse": helpers.ParamParse,
|
||||
"ParamDescription": helpers.ParamDescription,
|
||||
"bt": func() string { return "`" },
|
||||
}).Parse(templates.Action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, act); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes())
|
||||
|
||||
return os.WriteFile(filePath, formatted, 0644)
|
||||
}
|
||||
|
||||
// WriteRegistry генерирует registry.go со списком всех ресурсов.
|
||||
func WriteRegistry(outDir string, services []types.GenResource, subs []types.GenSubresource, actions []types.GenAction) error {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("package resources_gen\n\n")
|
||||
buf.WriteString("import \"github.com/hashicorp/terraform-plugin-framework/resource\"\n\n")
|
||||
buf.WriteString("// Code generated by tools/gen_v2. DO NOT EDIT.\n")
|
||||
buf.WriteString("func AllResources() []func() resource.Resource {\n")
|
||||
buf.WriteString("\treturn []func() resource.Resource{\n")
|
||||
for _, svc := range services {
|
||||
buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(svc.Name)))
|
||||
}
|
||||
for _, sr := range subs {
|
||||
buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(sr.ServiceName+"_"+sr.SubName)))
|
||||
}
|
||||
for _, act := range actions {
|
||||
buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(act.ServiceName+"_"+act.ActionName)))
|
||||
}
|
||||
buf.WriteString("\t}\n")
|
||||
buf.WriteString("}\n")
|
||||
|
||||
regPath := filepath.Join(outDir, "registry.go")
|
||||
formatted := helpers.FormatSourceOrWarn(regPath, buf.Bytes())
|
||||
|
||||
return os.WriteFile(regPath, formatted, 0644)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package client — HTTP-клиент для API Nubes.
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yaml-generator/normalize"
|
||||
"yaml-generator/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"
|
||||
|
||||
"yaml-generator/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")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module yaml-generator
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -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"
|
||||
|
||||
"yaml-generator/client"
|
||||
"yaml-generator/config"
|
||||
"yaml-generator/normalize"
|
||||
"yaml-generator/spec"
|
||||
"yaml-generator/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"
|
||||
|
||||
"yaml-generator/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