From aac18970ae53f896cd9189adf291ded9a106eccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Tue, 30 Jun 2026 15:44:56 +0400 Subject: [PATCH] add: generator tools (sources) --- .../tools/docs_template_gen/main.go | 744 ++++++ .../tools/docs_template_gen_v2/main.go | 1093 +++++++++ universal_rebuild/tools/gen/README.md | 6 + .../tools/gen/generate_resources.go | 996 ++++++++ .../tools/gen_v2/generate_resources_v2.go | 2164 +++++++++++++++++ .../tools/ops_docs_gen/generate_ops_docs.go | 289 +++ .../service_ops_gen/generate_service_ops.go | 492 ++++ .../generate_service_params.go | 523 ++++ .../service_spec_gen/generate_service_spec.go | 715 ++++++ 9 files changed, 7022 insertions(+) create mode 100644 universal_rebuild/tools/docs_template_gen/main.go create mode 100644 universal_rebuild/tools/docs_template_gen_v2/main.go create mode 100644 universal_rebuild/tools/gen/README.md create mode 100644 universal_rebuild/tools/gen/generate_resources.go create mode 100644 universal_rebuild/tools/gen_v2/generate_resources_v2.go create mode 100644 universal_rebuild/tools/ops_docs_gen/generate_ops_docs.go create mode 100644 universal_rebuild/tools/service_ops_gen/generate_service_ops.go create mode 100644 universal_rebuild/tools/service_params_gen/generate_service_params.go create mode 100644 universal_rebuild/tools/service_spec_gen/generate_service_spec.go diff --git a/universal_rebuild/tools/docs_template_gen/main.go b/universal_rebuild/tools/docs_template_gen/main.go new file mode 100644 index 0000000..cb20519 --- /dev/null +++ b/universal_rebuild/tools/docs_template_gen/main.go @@ -0,0 +1,744 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "html" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +type ParamSpec struct { + ID int `yaml:"id"` + Code string `yaml:"code"` + DataType string `yaml:"data_type"` + Type string `yaml:"type"` + Required bool `yaml:"required"` + Default interface{} `yaml:"default"` + Descr string `yaml:"descr"` + Man string `yaml:"man"` + RefSvcID *int `yaml:"ref_svc_id"` + Func string `yaml:"func"` + MinValue *int `yaml:"minvalue"` + MaxValue *int `yaml:"maxvalue"` + Regex string `yaml:"regex"` + ValueList []string `yaml:"value_list"` + Unique string `yaml:"unique_scope"` + MaxLength *int `yaml:"maxlength"` + MinLength *int `yaml:"minlength"` +} + +type OperationSpec struct { + Name string `yaml:"name"` + ID int `yaml:"id"` + Kind string `yaml:"kind"` + Action string `yaml:"action"` + Subresource string `yaml:"subresource"` + Man string `yaml:"man"` + Params []ParamSpec `yaml:"params"` +} + +type OutputParam struct { + Code string `yaml:"code"` + Type string `yaml:"type"` + Sensitive bool `yaml:"sensitive"` +} + +type ServiceSpec struct { + Name string `yaml:"name"` + ServiceID int `yaml:"service_id"` + ServiceDisplayName string `yaml:"service_display_name"` + ServiceMan string `yaml:"service_man"` + Lifecycle struct { + DeleteModeDefault string `yaml:"delete_mode_default"` + ResumeIfExistsDefault bool `yaml:"resume_if_exists_default"` + } `yaml:"lifecycle"` + Outputs struct { + Params []OutputParam `yaml:"params"` + } `yaml:"outputs"` + Operations []OperationSpec `yaml:"operations"` +} + +type ServiceMeta struct { + ID int + Name string +} + +func main() { + resourcesDirFlag := flag.String("resources", "", "Path to resources_yaml") + docsDirFlag := flag.String("docs", "", "Path to docs/30_registry/resources") + servicesListFlag := flag.String("services", "", "Path to devops/config/services_list.txt") + excludeFlag := flag.String("exclude", "clickhouse", "Comma-separated resource names to skip") + versionFlag := flag.String("version", "", "Provider version for example block") + flag.Parse() + + root := detectRoot() + resourcesDir := pickPath(*resourcesDirFlag, filepath.Join(root, "universal_rebuild", "resources_yaml")) + docsDir := pickPath(*docsDirFlag, filepath.Join(root, "docs", "30_registry", "resources")) + servicesList := pickPath(*servicesListFlag, filepath.Join(root, "devops", "config", "services_list.txt")) + excludeSet := toSet(*excludeFlag) + version := *versionFlag + if version == "" { + version = detectVersion(filepath.Join(root, "universal_rebuild", "main.go")) + } + + servicesOrder := loadServicesList(servicesList) + specs := loadSpecs(resourcesDir, servicesOrder) + if len(specs) == 0 { + fmt.Fprintf(os.Stderr, "No specs found in %s\n", resourcesDir) + os.Exit(1) + } + + if err := os.MkdirAll(docsDir, 0o755); err != nil { + panic(err) + } + + for _, spec := range specs { + if excludeSet[spec.Name] { + continue + } + writeResourceDocs(docsDir, spec, version) + } +} + +func detectRoot() string { + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + if filepath.Base(cwd) == "universal_rebuild" { + return filepath.Dir(cwd) + } + return cwd +} + +func pickPath(value, fallback string) string { + if value != "" { + return value + } + return fallback +} + +func toSet(csv string) map[string]bool { + out := map[string]bool{} + for _, item := range strings.Split(csv, ",") { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + out[trimmed] = true + } + return out +} + +func detectVersion(mainPath string) string { + b, err := os.ReadFile(mainPath) + if err != nil { + return "2.x" + } + re := regexp.MustCompile(`version string\s*=\s*"([0-9.]+)"`) + match := re.FindStringSubmatch(string(b)) + if len(match) > 1 { + return match[1] + } + return "2.x" +} + +func loadServicesList(path string) []ServiceMeta { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + lines := strings.Split(string(b), "\n") + out := []ServiceMeta{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + head := strings.SplitN(line, "#", 2)[0] + parts := strings.Fields(head) + if len(parts) < 2 { + continue + } + id, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + out = append(out, ServiceMeta{ID: id, Name: parts[1]}) + } + return out +} + +func loadSpecs(dir string, ordered []ServiceMeta) []ServiceSpec { + specsByID := map[int]ServiceSpec{} + 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 ServiceSpec + if err := yaml.Unmarshal(b, &spec); err != nil { + return err + } + specsByID[spec.ServiceID] = spec + return nil + }) + if walkErr != nil { + panic(walkErr) + } + + if len(ordered) == 0 { + specs := make([]ServiceSpec, 0, len(specsByID)) + for _, spec := range specsByID { + specs = append(specs, spec) + } + sort.Slice(specs, func(i, j int) bool { return specs[i].ServiceID < specs[j].ServiceID }) + return specs + } + + specs := []ServiceSpec{} + for _, meta := range ordered { + spec, ok := specsByID[meta.ID] + if !ok { + continue + } + specs = append(specs, spec) + } + return specs +} + +func writeResourceDocs(docsDir string, spec ServiceSpec, version string) { + base := spec.Name + nav := fmt.Sprintf("[Manual](%s.md) | [Create params](%s_params_create.md) | [Modify params](%s_params_modify.md) | [Output params](%s_outputs.md) | [Operations](%s_ops.md) | [Example](%s_example.md)", base, base, base, base, base, base) + + writeFile(filepath.Join(docsDir, base+".md"), buildManualPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_example.md"), buildExamplePage(spec, nav, version)) + writeFile(filepath.Join(docsDir, base+"_params_create.md"), buildCreateParamsPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_params_modify.md"), buildModifyParamsPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_outputs.md"), buildOutputsPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_ops.md"), buildOpsPage(spec, nav, docsDir)) + writeFile(filepath.Join(docsDir, base+"_params.md"), buildParamsLandingPage(spec, nav)) +} + +func writeFile(path, content string) { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + panic(err) + } +} + +func buildHeader(spec ServiceSpec, nav string) string { + name := spec.ServiceDisplayName + if name == "" { + name = spec.Name + } + return fmt.Sprintf("# Resource nubes_%s\n\nService ID: `%d`\n\nService Name: %s\n\n%s\n\n", spec.Name, spec.ServiceID, name, nav) +} + +func buildManualPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## MAN\n\n") + man := strings.TrimSpace(spec.ServiceMan) + if man == "" { + man = "Manual not available." + } + b.WriteString("
\n") + b.WriteString(man) + b.WriteString("\n
\n") + return b.String() +} + +func buildExamplePage(spec ServiceSpec, nav, version string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_main.tf`)\n\n", spec.Name)) + b.WriteString("
")
+	b.WriteString(exampleBlock(spec, version))
+	b.WriteString("
\n\n") + b.WriteString("## Outputs usage\n\n") + b.WriteString("
")
+	b.WriteString("# Выходные параметры можно использовать как\n")
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params[\"имя_ключа\"]\n", spec.Name))
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out[\"имя_ключа\"]\n", spec.Name))
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params_flat[\"имя_ключа\"]\n", spec.Name))
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out_flat[\"имя_ключа\"]\n", spec.Name))
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_secrets[\"имя_ключа\"]\n", spec.Name))
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_url\n", spec.Name))
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_user_path\n", spec.Name))
+	b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_fields\n", spec.Name))
+	b.WriteString("
\n") + return b.String() +} + +func exampleBlock(spec ServiceSpec, version string) string { + createParams := findParams(spec.Operations, "create") + requiredParams, defaultParams := splitParams(createParams) + + var b strings.Builder + b.WriteString("terraform {\n") + b.WriteString(" required_providers {\n") + b.WriteString(" nubes = {\n") + b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n") + b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version)) + b.WriteString(" }\n") + b.WriteString(" }\n") + b.WriteString("}\n\n") + b.WriteString("# Токен доступа api_token к Nubes API — замените на реальный.\n") + b.WriteString("provider \"nubes\" {\n") + b.WriteString(" api_token = \"token_***\"\n") + b.WriteString(" api_endpoint = \"https://deck-api.ngcloud.ru/api/v1/index.cfm\"\n") + b.WriteString("}\n") + b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name)) + + if len(requiredParams) > 0 { + b.WriteString(" # TODO - обязательный параметр, задаваемый пользователем\n") + } + for _, p := range requiredParams { + b.WriteString(formatParamLine(p, " ")) + } + + if len(defaultParams) > 0 { + b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n") + for _, p := range defaultParams { + b.WriteString(formatParamLine(p, " ")) + } + } + + b.WriteString("}\n") + return escapeCode(b.String()) +} + +func buildCreateParamsPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Create params\n\n") + createParams := findParams(spec.Operations, "create") + requiredParams, defaultParams := splitParams(createParams) + + b.WriteString("**Обязательные параметры (вводимые пользователем)**\n\n") + b.WriteString(renderParamTable(requiredParams, true, true)) + + b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n") + b.WriteString(renderParamTable(defaultParams, false, false)) + + lifecycle := spec.Lifecycle + if lifecycle.DeleteModeDefault != "" || lifecycle.ResumeIfExistsDefault { + b.WriteString("\n
\n") + b.WriteString("Параметры жизненного цикла (Lifecycle defaults)
\n") + b.WriteString(fmt.Sprintf("По умолчанию: `delete_mode_default = %s`, `resume_if_exists_default = %v`.
\n", lifecycle.DeleteModeDefault, lifecycle.ResumeIfExistsDefault)) + b.WriteString("Если для сервиса доступны операции `suspend` и `resume`, то `delete` выполняет приостановку (без удаления данных),
\n") + b.WriteString("а `apply` при необходимости возобновляет работу существующего ресурса.
\n") + b.WriteString("Менять эти значения стоит только в продвинутых сценариях — через параметры `delete_mode` и `resume_if_exists` в манифесте.\n") + b.WriteString("
\n") + } + + return b.String() +} + +func buildModifyParamsPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Modify params\n\n") + modifyParams := findParams(spec.Operations, "modify") + b.WriteString(renderModifyTable(modifyParams)) + return b.String() +} + +func buildOutputsPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Output params\n\n") + if len(spec.Outputs.Params) == 0 { + b.WriteString("None.\n") + return b.String() + } + for _, p := range spec.Outputs.Params { + b.WriteString(fmt.Sprintf("- `%s` (%s)\n", p.Code, p.Type)) + } + return b.String() +} + +func buildOpsPage(spec ServiceSpec, nav, docsDir string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Operations\n\n") + for _, op := range spec.Operations { + name := op.Name + if name == "" { + name = op.Action + } + descr := strings.TrimSpace(stripHTML(op.Man)) + if descr != "" { + descr = ": " + descr + } + switch op.Action { + case "create": + b.WriteString(fmt.Sprintf("- `%s` — Создание кластера. Параметры: [%s params](%s_params_create.md)\n", name, "Create", spec.Name)) + continue + case "modify": + b.WriteString(fmt.Sprintf("- `%s` — Изменение кластера. Параметры: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name)) + continue + } + + if op.Kind == "subresource" && op.Subresource != "" { + subFile := fmt.Sprintf("%s_%s.md", spec.Name, slug(op.Subresource)) + if fileExists(filepath.Join(docsDir, subFile)) { + b.WriteString(fmt.Sprintf("- `%s` — %s. См. [nubes_%s_%s](%s)\n", name, strings.TrimPrefix(descr, ": "), spec.Name, slug(op.Subresource), subFile)) + continue + } + } + if descr != "" { + b.WriteString(fmt.Sprintf("- `%s` — %s\n", name, strings.TrimPrefix(descr, ": "))) + } else { + b.WriteString(fmt.Sprintf("- `%s`\n", name)) + } + } + return b.String() +} + +func buildParamsLandingPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Parameters\n\n") + b.WriteString(fmt.Sprintf("- [Create params](%s_params_create.md)\n", spec.Name)) + b.WriteString(fmt.Sprintf("- [Modify params](%s_params_modify.md)\n", spec.Name)) + return b.String() +} + +func findParams(ops []OperationSpec, action string) []ParamSpec { + for _, op := range ops { + if op.Kind == "instance" && op.Action == action { + return op.Params + } + } + return nil +} + +func splitParams(params []ParamSpec) ([]ParamSpec, []ParamSpec) { + required := []ParamSpec{} + defaults := []ParamSpec{} + for _, p := range params { + if hasDefault(p.Default) { + defaults = append(defaults, p) + continue + } + required = append(required, p) + } + return required, defaults +} + +func hasDefault(value interface{}) bool { + if value == nil { + return false + } + if s, ok := value.(string); ok { + return strings.TrimSpace(s) != "" + } + return true +} + +func renderParamTable(params []ParamSpec, requiredTable, noDefault bool) string { + if len(params) == 0 { + return "None.\n" + } + var b strings.Builder + tableClass := "resource-table resource-table-compact" + if requiredTable { + tableClass += " resource-table-required" + } + b.WriteString(fmt.Sprintf("\n", tableClass)) + if noDefault { + b.WriteString("\n\n") + } else { + b.WriteString("\n\n") + } + for _, p := range params { + b.WriteString("") + b.WriteString(fmt.Sprintf("", escapeText(formatID(p.ID)))) + b.WriteString(fmt.Sprintf("", formatCode(p.Code))) + b.WriteString(fmt.Sprintf("", formatTypeCell(p))) + if !noDefault { + b.WriteString(fmt.Sprintf("", defaultCell(p.Default))) + } + b.WriteString(fmt.Sprintf("", escapeText(pickText(p)))) + b.WriteString(fmt.Sprintf("", escapeText(collectConstraints(p)))) + b.WriteString("\n") + } + b.WriteString("
IDCodeTypeDescriptionConstraints
IDCodeTypeDefaultDescriptionConstraints
%s%s%s%s%s%s
\n") + return b.String() +} + +func renderModifyTable(params []ParamSpec) string { + if len(params) == 0 { + return "None.\n" + } + var b strings.Builder + b.WriteString("\n") + b.WriteString("\n\n") + for _, p := range params { + b.WriteString("") + b.WriteString(fmt.Sprintf("", escapeText(formatID(p.ID)))) + b.WriteString(fmt.Sprintf("", formatCode(p.Code))) + b.WriteString(fmt.Sprintf("", formatTypeCell(p))) + b.WriteString(fmt.Sprintf("", escapeText(pickText(p)))) + b.WriteString(fmt.Sprintf("", escapeText(collectConstraints(p)))) + b.WriteString("\n") + } + b.WriteString("
IDCodeTypeDescriptionConstraints
%s%s%s%s%s
\n") + return b.String() +} + +func formatParamLine(p ParamSpec, indent string) string { + value := sampleValue(p) + comment := strings.TrimSpace(stripHTML(p.Man)) + if comment == "" { + comment = strings.TrimSpace(stripHTML(p.Descr)) + } + if comment != "" { + return fmt.Sprintf("%s%s = %s # %s\n", indent, p.Code, value, comment) + } + return fmt.Sprintf("%s%s = %s\n", indent, p.Code, value) +} + +func sampleValue(p ParamSpec) string { + if hasDefault(p.Default) { + return formatLiteral(p.Default) + } + if len(p.ValueList) > 0 { + return formatLiteral(p.ValueList[0]) + } + dtype := strings.ToLower(firstType(p)) + switch { + case strings.Contains(dtype, "bool"): + return "false" + case strings.Contains(dtype, "int") || strings.Contains(dtype, "number"): + return "1" + case strings.Contains(dtype, "json"): + return "{}" + } + return "\"TODO\"" +} + +func formatLiteral(value interface{}) string { + switch v := value.(type) { + case bool: + if v { + return "true" + } + return "false" + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case string: + if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") { + return v + } + if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") { + return v + } + if looksLikeNumber(v) || v == "true" || v == "false" { + return v + } + return fmt.Sprintf("\"%s\"", v) + default: + return fmt.Sprintf("\"%v\"", v) + } +} + +func looksLikeNumber(value string) bool { + if value == "" { + return false + } + if value[0] == '-' { + value = value[1:] + } + for _, ch := range value { + if ch == '.' { + continue + } + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func formatID(id int) string { + if id == 0 { + return "" + } + return strconv.Itoa(id) +} + +func formatTypeCell(p ParamSpec) string { + dtype := firstType(p) + if dtype == "" { + return "" + } + return "" + escapeText(dtype) + "" +} + +func firstType(p ParamSpec) string { + if p.DataType != "" { + return normalizeType(p.DataType) + } + return normalizeType(p.Type) +} + +func normalizeType(dtype string) string { + cleaned := strings.ReplaceAll(dtype, ">", ">") + if idx := strings.Index(cleaned, ">"); idx >= 0 { + cleaned = cleaned[:idx] + } + return strings.TrimSpace(cleaned) +} + +func defaultCell(value interface{}) string { + if !hasDefault(value) { + return "" + } + return "" + escapeText(formatLiteral(value)) + "" +} + +func pickText(p ParamSpec) string { + if p.Descr == "" && p.Man == "" { + return "" + } + if p.Man == "" { + return p.Descr + } + if p.Descr == "" { + return p.Man + } + if len(p.Man) > len(p.Descr) { + return p.Man + } + return p.Descr +} + +func collectConstraints(p ParamSpec) string { + parts := []string{} + if p.MinValue != nil { + parts = append(parts, fmt.Sprintf("minvalue=%d", *p.MinValue)) + } + if p.MaxValue != nil { + parts = append(parts, fmt.Sprintf("maxvalue=%d", *p.MaxValue)) + } + if p.Regex != "" { + parts = append(parts, fmt.Sprintf("regex=%s", p.Regex)) + } + if len(p.ValueList) > 0 { + parts = append(parts, fmt.Sprintf("value_list=%s", strings.Join(p.ValueList, ", "))) + } + if p.Func != "" { + parts = append(parts, fmt.Sprintf("func=%s", p.Func)) + } + if p.RefSvcID != nil { + parts = append(parts, fmt.Sprintf("ref_svc_id=%d", *p.RefSvcID)) + } + if p.Unique != "" { + parts = append(parts, fmt.Sprintf("unique_scope=%s", p.Unique)) + } + if p.MaxLength != nil { + parts = append(parts, fmt.Sprintf("maxlength=%d", *p.MaxLength)) + } + if p.MinLength != nil { + parts = append(parts, fmt.Sprintf("minlength=%d", *p.MinLength)) + } + return strings.Join(parts, "; ") +} + +func escapeText(value string) string { + if value == "" { + return "" + } + text := html.EscapeString(value) + text = strings.ReplaceAll(text, "<br/>", "
") + text = strings.ReplaceAll(text, "<br />", "
") + text = strings.ReplaceAll(text, "<br>", "
") + return text +} + +func escapeCode(value string) string { + buf := bytes.NewBuffer(nil) + for _, line := range strings.Split(value, "\n") { + buf.WriteString(html.EscapeString(line)) + buf.WriteString("\n") + } + return buf.String() +} + +func formatCode(code string) string { + if code == "" { + return "" + } + if len(code) <= 40 { + return "" + code + "" + } + out := []string{} + prev := rune(0) + for _, ch := range code { + if ch == '_' { + out = append(out, "_") + prev = ch + continue + } + if prev != 0 && (unicodeIsLower(prev) || unicodeIsDigit(prev)) && unicodeIsUpper(ch) { + out = append(out, "") + } + out = append(out, string(ch)) + prev = ch + } + return "" + strings.Join(out, "") + "" +} + +func unicodeIsLower(r rune) bool { return r >= 'a' && r <= 'z' } +func unicodeIsUpper(r rune) bool { return r >= 'A' && r <= 'Z' } +func unicodeIsDigit(r rune) bool { return r >= '0' && r <= '9' } + +func stripHTML(value string) string { + if value == "" { + return "" + } + re := regexp.MustCompile("<[^>]+>") + cleaned := re.ReplaceAllString(value, " ") + cleaned = strings.Join(strings.Fields(cleaned), " ") + return html.UnescapeString(cleaned) +} + +func slug(value string) string { + out := []rune{} + for _, ch := range value { + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' { + out = append(out, ch) + } else if ch >= 'A' && ch <= 'Z' { + out = append(out, ch+'a'-'A') + } else { + out = append(out, '_') + } + } + return strings.Trim(string(out), "_") +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/universal_rebuild/tools/docs_template_gen_v2/main.go b/universal_rebuild/tools/docs_template_gen_v2/main.go new file mode 100644 index 0000000..d7988fd --- /dev/null +++ b/universal_rebuild/tools/docs_template_gen_v2/main.go @@ -0,0 +1,1093 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "html" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +type ParamSpec struct { + ID int `yaml:"id"` + Code string `yaml:"code"` + DataType string `yaml:"data_type"` + Type string `yaml:"type"` + Required bool `yaml:"required"` + Default interface{} `yaml:"default"` + Descr string `yaml:"descr"` + Man string `yaml:"man"` + RefSvcID *int `yaml:"ref_svc_id"` + Func string `yaml:"func"` + MinValue *int `yaml:"minvalue"` + MaxValue *int `yaml:"maxvalue"` + Regex string `yaml:"regex"` + ValueList []string `yaml:"value_list"` + Unique string `yaml:"unique_scope"` + MaxLength *int `yaml:"maxlength"` + MinLength *int `yaml:"minlength"` +} + +type OperationSpec struct { + Name string `yaml:"name"` + ID int `yaml:"id"` + Kind string `yaml:"kind"` + Action string `yaml:"action"` + Subresource string `yaml:"subresource"` + Man string `yaml:"man"` + Params []ParamSpec `yaml:"params"` +} + +type OutputParam struct { + Code string `yaml:"code"` + Type string `yaml:"type"` + Sensitive bool `yaml:"sensitive"` +} + +type ServiceSpec struct { + Name string `yaml:"name"` + ServiceID int `yaml:"service_id"` + ServiceDisplayName string `yaml:"service_display_name"` + ServiceMan string `yaml:"service_man"` + Lifecycle struct { + SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"` + AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"` + } `yaml:"lifecycle"` + Outputs struct { + Params []OutputParam `yaml:"params"` + } `yaml:"outputs"` + Operations []OperationSpec `yaml:"operations"` +} + +type ServiceMeta struct { + ID int + Name string +} + +type CloudOutputSnapshot struct { + ServiceID int `json:"serviceId"` + ServiceName string `json:"serviceName"` + OutPaths []string `json:"out_paths"` + VaultFields []string `json:"vault_fields"` +} + +var cloudOutputByServiceID = map[int]CloudOutputSnapshot{} + +func main() { + resourcesDirFlag := flag.String("resources", "", "Path to resources_yaml") + docsDirFlag := flag.String("docs", "", "Path to docs/30_registry/resources") + servicesListFlag := flag.String("services", "", "Path to devops/config/services_list.txt") + excludeFlag := flag.String("exclude", "clickhouse", "Comma-separated resource names to skip") + versionFlag := flag.String("version", "", "Provider version for example block") + flag.Parse() + + root := detectRoot() + resourcesDir := pickPath(*resourcesDirFlag, filepath.Join(root, "universal_rebuild", "resources_yaml")) + docsDir := pickPath(*docsDirFlag, filepath.Join(root, "docs", "30_registry", "resources")) + servicesList := pickPath(*servicesListFlag, filepath.Join(root, "devops", "config", "services_list.txt")) + cloudOutputByServiceID = loadCloudOutputSnapshot(root) + excludeSet := toSet(*excludeFlag) + version := *versionFlag + if version == "" { + version = detectVersion(filepath.Join(root, "universal_rebuild", "main.go")) + } + + servicesOrder := loadServicesList(servicesList) + specs := loadSpecs(resourcesDir, servicesOrder) + if len(specs) == 0 { + fmt.Fprintf(os.Stderr, "No specs found in %s\n", resourcesDir) + os.Exit(1) + } + + if err := os.MkdirAll(docsDir, 0o755); err != nil { + panic(err) + } + + var processedSpecs []ServiceSpec + for _, spec := range specs { + if excludeSet[spec.Name] { + continue + } + writeResourceDocs(docsDir, spec, version) + processedSpecs = append(processedSpecs, spec) + } + generateIndexMD(docsDir, processedSpecs) +} + +func detectRoot() string { + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + if filepath.Base(cwd) == "universal_rebuild" { + return filepath.Dir(cwd) + } + return cwd +} + +func pickPath(value, fallback string) string { + if value != "" { + return value + } + return fallback +} + +func toSet(csv string) map[string]bool { + out := map[string]bool{} + for _, item := range strings.Split(csv, ",") { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + out[trimmed] = true + } + return out +} + +func loadCloudOutputSnapshot(root string) map[int]CloudOutputSnapshot { + out := map[int]CloudOutputSnapshot{} + base := filepath.Join(root, "docs", "70_api") + entries, err := os.ReadDir(base) + if err != nil { + return out + } + + latestDir := "" + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, "output_inventory_snapshot_") { + if name > latestDir { + latestDir = name + } + } + } + if latestDir == "" { + return out + } + + path := filepath.Join(base, latestDir, "running_suspended_output_fields_for_docs.json") + b, err := os.ReadFile(path) + if err != nil { + return out + } + + var items []CloudOutputSnapshot + if err := json.Unmarshal(b, &items); err != nil { + return out + } + + for _, item := range items { + out[item.ServiceID] = item + } + + return out +} + +func detectVersion(mainPath string) string { + b, err := os.ReadFile(mainPath) + if err != nil { + return "2.x" + } + re := regexp.MustCompile(`version string\s*=\s*"([0-9.]+)"`) + match := re.FindStringSubmatch(string(b)) + if len(match) > 1 { + return match[1] + } + return "2.x" +} + +func loadServicesList(path string) []ServiceMeta { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + lines := strings.Split(string(b), "\n") + out := []ServiceMeta{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + head := strings.SplitN(line, "#", 2)[0] + parts := strings.Fields(head) + if len(parts) < 2 { + continue + } + id, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + out = append(out, ServiceMeta{ID: id, Name: parts[1]}) + } + return out +} + +func loadSpecs(dir string, ordered []ServiceMeta) []ServiceSpec { + specsByID := map[int]ServiceSpec{} + 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 ServiceSpec + if err := yaml.Unmarshal(b, &spec); err != nil { + return err + } + specsByID[spec.ServiceID] = spec + return nil + }) + if walkErr != nil { + panic(walkErr) + } + + if len(ordered) == 0 { + specs := make([]ServiceSpec, 0, len(specsByID)) + for _, spec := range specsByID { + specs = append(specs, spec) + } + sort.Slice(specs, func(i, j int) bool { return specs[i].ServiceID < specs[j].ServiceID }) + return specs + } + + specs := []ServiceSpec{} + for _, meta := range ordered { + spec, ok := specsByID[meta.ID] + if !ok { + continue + } + specs = append(specs, spec) + } + return specs +} + +// collectSubresources возвращает уникальные subresource-имена сервиса. +func collectSubresources(spec ServiceSpec) []string { + seen := map[string]bool{} + var out []string + for _, op := range spec.Operations { + if op.Kind == "subresource" && op.Subresource != "" { + if !seen[op.Subresource] { + seen[op.Subresource] = true + out = append(out, op.Subresource) + } + } + } + return out +} + +func writeResourceDocs(docsDir string, spec ServiceSpec, version string) { + base := spec.Name + nav := fmt.Sprintf("[Manual](%s.md) | [Create params](%s_params_create.md) | [Modify params](%s_params_modify.md) | [Output params](%s_outputs.md) | [Operations](%s_ops.md) | [Example](%s_example.md)", base, base, base, base, base, base) + + writeFile(filepath.Join(docsDir, base+".md"), buildManualPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_example.md"), buildExamplePage(spec, nav, version)) + writeFile(filepath.Join(docsDir, base+"_params_create.md"), buildCreateParamsPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_params_modify.md"), buildModifyParamsPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_outputs.md"), buildOutputsPage(spec, nav)) + writeFile(filepath.Join(docsDir, base+"_ops.md"), buildOpsPage(spec, nav, docsDir)) + writeFile(filepath.Join(docsDir, base+"_params.md"), buildParamsLandingPage(spec, nav)) + + // Отдельные страницы для каждого subresource (nubes_{service}_{subresource}) + for _, srName := range collectSubresources(spec) { + srBase := fmt.Sprintf("%s_%s", base, slug(srName)) + srNav := fmt.Sprintf("[%s (основной)](%s.md) | [Operations](%s_ops.md) | [%s](%s.md) | [Example](%s_example.md)", + spec.ServiceDisplayName, base, base, capitalize(srName), srBase, srBase) + writeFile(filepath.Join(docsDir, srBase+".md"), buildSubresourcePage(spec, srName, srNav)) + writeFile(filepath.Join(docsDir, srBase+"_example.md"), buildSubresourceExamplePage(spec, srName, srNav, version)) + } +} + +func writeFile(path, content string) { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + panic(err) + } +} + +func buildHeader(spec ServiceSpec, nav string) string { + name := spec.ServiceDisplayName + if name == "" { + name = spec.Name + } + return fmt.Sprintf("# Resource nubes_%s\n\nService ID: `%d`\n\nService Name: %s\n\n%s\n\n", spec.Name, spec.ServiceID, name, nav) +} + +func buildManualPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## MAN\n\n") + man := strings.TrimSpace(spec.ServiceMan) + if man == "" { + man = "Manual not available." + } + b.WriteString("
\n") + b.WriteString(man) + b.WriteString("\n
\n") + return b.String() +} + +func buildExamplePage(spec ServiceSpec, nav, version string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_main.tf`)\n\n", spec.Name)) + b.WriteString("```hcl\n") + b.WriteString(exampleBlock(spec, version)) + b.WriteString("```\n\n") + b.WriteString("## Outputs usage\n\n") + b.WriteString("```hcl\n") + b.WriteString("# \u0412\u044b\u0445\u043e\u0434\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043c\u043e\u0436\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043a\u0430\u043a\n") + b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name)) + b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name)) + b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params_flat[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name)) + b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out_flat[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name)) + b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_secrets[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name)) + b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_url\n", spec.Name)) + b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_user_path\n", spec.Name)) + b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_fields\n", spec.Name)) + b.WriteString("```\n") + return b.String() +} + +func exampleBlock(spec ServiceSpec, version string) string { + createParams := findParams(spec.Operations, "create") + requiredParams, defaultParams := splitParams(createParams) + + var b strings.Builder + b.WriteString("terraform {\n") + b.WriteString(" required_providers {\n") + b.WriteString(" nubes = {\n") + b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n") + b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version)) + b.WriteString(" }\n") + b.WriteString(" }\n") + b.WriteString("}\n\n") + b.WriteString("# \u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 api_token \u043a Nubes API \u2014 \u0437\u0430\u043c\u0435\u043d\u0438\u0442\u0435 \u043d\u0430 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0439.\n") + b.WriteString("provider \"nubes\" {\n") + b.WriteString(" api_token = \"token_***\"\n") + b.WriteString(" api_endpoint = \"https://deck-api.ngcloud.ru/api/v1/index.cfm\"\n") + b.WriteString("}\n") + b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name)) + + for _, p := range requiredParams { + b.WriteString(formatParamLine(p, " ", true)) + } + + if len(defaultParams) > 0 { + b.WriteString("\n # \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u0438\u043c\u0435\u044e\u0449\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u0435\u0441\u043b\u0438 \u043d\u0435 \u043c\u0435\u043d\u044f\u0435\u0442\u0435 - \u044d\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043d\u0435 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u0440\u043e\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0432 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0435\n") + for _, p := range defaultParams { + b.WriteString(formatParamLine(p, " ", false)) + } + } + + b.WriteString("}\n") + return b.String() +} + +func buildCreateParamsPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Create params\n\n") + createParams := findParams(spec.Operations, "create") + requiredParams, defaultParams := splitParams(createParams) + + b.WriteString("**\u041e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b (\u0432\u0432\u043e\u0434\u0438\u043c\u044b\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u043c)**\n\n") + b.WriteString(renderParamTable(requiredParams, true, true)) + + b.WriteString("\n**\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u0438\u043c\u0435\u044e\u0449\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u0435\u0441\u043b\u0438 \u043d\u0435 \u043c\u0435\u043d\u044f\u0435\u0442\u0435 - \u044d\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043d\u0435 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u0440\u043e\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0432 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0435**\n") + b.WriteString(renderParamTable(defaultParams, false, false)) + + lifecycle := spec.Lifecycle + if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault { + b.WriteString("\n
\n") + b.WriteString("\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f (\u043a\u0440\u0430\u0442\u043a\u043e)
\n") + b.WriteString(fmt.Sprintf("\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: `suspend_on_destroy = %s`, `adopt_existing_on_create = %s`.
\n", formatBoolTitle(lifecycle.SuspendOnDestroyDefault), formatBoolTitle(lifecycle.AdoptExistingOnCreateDefault))) + b.WriteString("\u041f\u0440\u0438 `terraform destroy` \u0438\u043b\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0438 \u0440\u0435\u0441\u0443\u0440\u0441\u0430 \u0438\u0437 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0430:
\n") + b.WriteString("- `suspend_on_destroy=true` \u2014 \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u043f\u0435\u0440\u0435\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0432 `Suspend` (\u043d\u0435 \u0443\u0434\u0430\u043b\u044f\u0435\u0442\u0441\u044f).
\n") + b.WriteString("- `suspend_on_destroy=false` \u2014 Terraform \u0443\u0434\u0430\u043b\u044f\u0435\u0442 \u0440\u0435\u0441\u0443\u0440\u0441 \u0442\u043e\u043b\u044c\u043a\u043e \u0438\u0437 state.
\n") + b.WriteString("\u041f\u0440\u0438 `apply` \u0444\u043b\u0430\u0433 `adopt_existing_on_create` \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043a\u0430\u043a \u0430\u0432\u0442\u043e-`import`:
\n") + b.WriteString("- `false` \u2014 \u0435\u0441\u043b\u0438 \u0440\u0435\u0441\u0443\u0440\u0441 \u0443\u0436\u0435 \u0435\u0441\u0442\u044c, \u0431\u0443\u0434\u0435\u0442 \u043e\u0448\u0438\u0431\u043a\u0430.
\n") + b.WriteString("- `true` \u2014 Terraform \u043c\u043e\u0436\u0435\u0442 \u0432\u0437\u044f\u0442\u044c \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0439 \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u043f\u043e\u0434 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 (`running` \u2192 adopt, `suspended` \u2192 resume+adopt \u043f\u0440\u0438 \u0441\u043e\u0432\u043f\u0430\u0434\u0435\u043d\u0438\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432).
\n") + b.WriteString("\u0412\u0430\u0436\u043d\u043e: \u043e\u0434\u0438\u043d \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u043e\u0434\u043d\u043e\u043c state. \u0418\u043d\u0430\u0447\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f.\n") + b.WriteString("
\n") + } + + return b.String() +} + +func buildModifyParamsPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Modify params\n\n") + modifyParams := findParams(spec.Operations, "modify") + b.WriteString(renderModifyTable(modifyParams)) + return b.String() +} + +func buildOutputsPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Output params\n\n") + if len(spec.Outputs.Params) == 0 { + b.WriteString("None.\n") + return b.String() + } + for _, p := range spec.Outputs.Params { + desc := outputDescription(p.Code) + if desc != "" { + b.WriteString(fmt.Sprintf("- `%s` (%s) \u2014 %s\n", p.Code, p.Type, desc)) + } else { + b.WriteString(fmt.Sprintf("- `%s` (%s)\n", p.Code, p.Type)) + } + } + + snapshot, ok := cloudOutputByServiceID[spec.ServiceID] + if !ok { + return b.String() + } + + hasOut := len(snapshot.OutPaths) > 0 + hasVault := len(snapshot.VaultFields) > 0 + if !hasOut && !hasVault { + return b.String() + } + + b.WriteString("\n## Реальные поля из облака (running/suspended snapshot)\n\n") + b.WriteString("Поля ниже получены из реальных инстансов этого сервиса в облаке.\n") + b.WriteString("Используйте их как готовые ключи для `state_out_flat` и `vault_secrets`.\n\n") + + if hasOut { + b.WriteString("### `state_out_flat` ключи\n\n") + for _, key := range snapshot.OutPaths { + if strings.TrimSpace(key) == "" { + continue + } + b.WriteString(fmt.Sprintf("- `state_out_flat[\"%s\"]`\n", key)) + } + b.WriteString("\n") + } + + if hasVault { + b.WriteString("### `vault_secrets` ключи\n\n") + for _, key := range snapshot.VaultFields { + if strings.TrimSpace(key) == "" { + continue + } + b.WriteString(fmt.Sprintf("- `vault_secrets[\"%s\"]`\n", key)) + } + b.WriteString("\n") + } + + if spec.ServiceID == 90 && containsString(snapshot.OutPaths, "internalConnect.master") && containsString(snapshot.VaultFields, "adminUser") && containsString(snapshot.VaultFields, "adminPass") { + b.WriteString("### Пример для связки с Lucee/NodeJS\n\n") + b.WriteString("```hcl\n") + b.WriteString("testds_connectionString = \"jdbc:postgresql://${nubes_postgres.db2.state_out_flat[\"internalConnect.master\"]}:5432/postgres?sslmode=require\"\n") + b.WriteString("testds_username = nubes_postgres.db2.vault_secrets[\"adminUser\"]\n") + b.WriteString("testds_password = nubes_postgres.db2.vault_secrets[\"adminPass\"]\n") + b.WriteString("```\n") + } + + return b.String() +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func buildOpsPage(spec ServiceSpec, nav, docsDir string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Operations\n\n") + for _, op := range spec.Operations { + name := op.Name + if name == "" { + name = op.Action + } + descr := strings.TrimSpace(stripHTML(op.Man)) + if descr != "" { + descr = ": " + descr + } + // Subresource-операции проверяем ДО switch, чтобы create_user/create_database + // не попадали в case "create" основного ресурса + if op.Kind == "subresource" { + descrStr := strings.TrimPrefix(descr, ": ") + if op.Subresource != "" { + // Ссылаемся на отдельную страницу subresource — она будет создана в writeResourceDocs + subFile := fmt.Sprintf("%s_%s.md", spec.Name, slug(op.Subresource)) + b.WriteString(fmt.Sprintf("- `%s` \u2014 %s. \u0421\u043c. [nubes_%s_%s](%s)\n", name, descrStr, spec.Name, slug(op.Subresource), subFile)) + } else { + // Нет subresource-имени — выводим инлайн + if descrStr != "" { + b.WriteString(fmt.Sprintf("\n#### `%s` \u2014 %s\n\n", name, descrStr)) + } else { + b.WriteString(fmt.Sprintf("\n#### `%s`\n\n", name)) + } + if len(op.Params) > 0 { + b.WriteString(renderParamTable(op.Params, true, true)) + b.WriteString("\n") + } + } + continue + } + + switch op.Action { + case "create": + b.WriteString(fmt.Sprintf("- `%s` \u2014 \u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0430. \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b: [%s params](%s_params_create.md)\n", name, "Create", spec.Name)) + case "modify": + b.WriteString(fmt.Sprintf("- `%s` \u2014 \u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0430. \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name)) + default: + if descr != "" { + b.WriteString(fmt.Sprintf("- `%s` \u2014 %s\n", name, strings.TrimPrefix(descr, ": "))) + } else { + b.WriteString(fmt.Sprintf("- `%s`\n", name)) + } + } + } + return b.String() +} + +func buildParamsLandingPage(spec ServiceSpec, nav string) string { + var b strings.Builder + b.WriteString(buildHeader(spec, nav)) + b.WriteString("## Parameters\n\n") + b.WriteString(fmt.Sprintf("- [Create params](%s_params_create.md)\n", spec.Name)) + b.WriteString(fmt.Sprintf("- [Modify params](%s_params_modify.md)\n", spec.Name)) + return b.String() +} + +func findParams(ops []OperationSpec, action string) []ParamSpec { + for _, op := range ops { + if op.Kind == "instance" && op.Action == action { + return op.Params + } + } + return nil +} + +func splitParams(params []ParamSpec) ([]ParamSpec, []ParamSpec) { + required := []ParamSpec{} + defaults := []ParamSpec{} + for _, p := range params { + if hasDefault(p.Default) { + defaults = append(defaults, p) + continue + } + required = append(required, p) + } + return required, defaults +} + +func hasDefault(value interface{}) bool { + if value == nil { + return false + } + if s, ok := value.(string); ok { + return strings.TrimSpace(s) != "" + } + return true +} + +func renderParamTable(params []ParamSpec, requiredTable, noDefault bool) string { + if len(params) == 0 { + return "None.\n" + } + var b strings.Builder + tableClass := "resource-table resource-table-compact" + if requiredTable { + tableClass += " resource-table-required" + } + b.WriteString(fmt.Sprintf("\n", tableClass)) + if noDefault { + b.WriteString("\n\n") + } else { + b.WriteString("\n\n") + } + for _, p := range params { + b.WriteString("") + b.WriteString(fmt.Sprintf("", escapeText(formatID(p.ID)))) + b.WriteString(fmt.Sprintf("", formatParamCode(p.Code))) + b.WriteString(fmt.Sprintf("", formatTypeCell(p))) + if !noDefault { + b.WriteString(fmt.Sprintf("", defaultCell(p.Default))) + } + b.WriteString(fmt.Sprintf("", escapeText(pickTextTable(p)))) + b.WriteString(fmt.Sprintf("", escapeText(collectConstraints(p)))) + b.WriteString("\n") + } + b.WriteString("
IDCodeTypeDescriptionConstraints
IDCodeTypeDefaultDescriptionConstraints
%s%s%s%s%s%s
\n") + return b.String() +} + +func renderModifyTable(params []ParamSpec) string { + if len(params) == 0 { + return "None.\n" + } + var b strings.Builder + b.WriteString("\n") + b.WriteString("\n\n") + for _, p := range params { + b.WriteString("") + b.WriteString(fmt.Sprintf("", escapeText(formatID(p.ID)))) + b.WriteString(fmt.Sprintf("", formatParamCode(p.Code))) + b.WriteString(fmt.Sprintf("", formatTypeCell(p))) + b.WriteString(fmt.Sprintf("", escapeText(pickTextTable(p)))) + b.WriteString(fmt.Sprintf("", escapeText(collectConstraints(p)))) + b.WriteString("\n") + } + b.WriteString("
IDCodeTypeDescriptionConstraints
%s%s%s%s%s
\n") + return b.String() +} + +func formatParamLine(p ParamSpec, indent string, requiredOnly bool) string { + if requiredOnly && !p.Required { + return "" + } + value := sampleValue(p) + comment := strings.TrimSpace(stripHTML(p.Descr)) + if comment == "" { + comment = strings.TrimSpace(stripHTML(p.Man)) + } + paramCode := toSnake(p.Code) + if comment != "" { + return fmt.Sprintf("%s%s = %s # %s\n", indent, paramCode, value, comment) + } + return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value) +} + +func sampleValue(p ParamSpec) string { + if hasDefault(p.Default) { + return formatLiteral(p.Default) + } + if len(p.ValueList) > 0 { + return formatLiteral(p.ValueList[0]) + } + dtype := strings.ToLower(firstType(p)) + switch { + case strings.Contains(dtype, "bool"): + return "false" + case strings.Contains(dtype, "int") || strings.Contains(dtype, "number"): + return "1" + case strings.Contains(dtype, "json"): + return "{}" + } + return "\"TODO\"" +} + +func formatLiteral(value interface{}) string { + switch v := value.(type) { + case bool: + if v { + return "true" + } + return "false" + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case string: + if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") { + return v + } + if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") { + return v + } + if looksLikeNumber(v) || v == "true" || v == "false" { + return v + } + return fmt.Sprintf("\"%s\"", v) + default: + return fmt.Sprintf("\"%v\"", v) + } +} + +func looksLikeNumber(value string) bool { + if value == "" { + return false + } + if value[0] == '-' { + value = value[1:] + } + for _, ch := range value { + if ch == '.' { + continue + } + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func formatID(id int) string { + if id == 0 { + return "" + } + return strconv.Itoa(id) +} + +func formatTypeCell(p ParamSpec) string { + dtype := firstType(p) + if dtype == "" { + return "" + } + return "" + escapeText(dtype) + "" +} + +func firstType(p ParamSpec) string { + if p.DataType != "" { + return normalizeType(p.DataType) + } + return normalizeType(p.Type) +} + +func normalizeType(dtype string) string { + cleaned := strings.ReplaceAll(dtype, ">", ">") + if idx := strings.Index(cleaned, ">"); idx >= 0 { + cleaned = cleaned[:idx] + } + return strings.TrimSpace(cleaned) +} + +func defaultCell(value interface{}) string { + if !hasDefault(value) { + return "" + } + if s, ok := value.(string); ok { + return "" + escapeText(s) + "" + } + return "" + escapeText(formatLiteral(value)) + "" +} + +func pickTextTable(p ParamSpec) string { + if p.Man != "" { + return p.Man + } + return p.Descr +} + +func outputDescription(code string) string { + switch code { + case "state_params": + return "\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043d\u044b\u0435 \u0432 API \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438/\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438" + case "state_out": + return "\u043e\u0442\u0432\u0435\u0442 API \u0441 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043c\u0438/\u0432\u044b\u0445\u043e\u0434\u043d\u044b\u043c\u0438 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043c\u0438" + case "state_params_flat": + return "\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0432 \u043f\u043b\u043e\u0441\u043a\u043e\u043c \u0432\u0438\u0434\u0435 (\u043a\u043b\u044e\u0447\u0438 \u0441 \u043f\u0443\u0442\u044f\u043c\u0438)" + case "state_out_flat": + return "\u043e\u0442\u0432\u0435\u0442\u044b \u0432 \u043f\u043b\u043e\u0441\u043a\u043e\u043c \u0432\u0438\u0434\u0435 (\u043a\u043b\u044e\u0447\u0438 \u0441 \u043f\u0443\u0442\u044f\u043c\u0438)" + case "vault_secrets": + return "\u0441\u0435\u043a\u0440\u0435\u0442\u044b, \u0437\u0430\u043f\u0438\u0441\u0430\u043d\u043d\u044b\u0435 \u0432 Vault \u0434\u043b\u044f \u0440\u0435\u0441\u0443\u0440\u0441\u0430" + case "vault_url": + return "\u0430\u0434\u0440\u0435\u0441 Vault \u0434\u043b\u044f \u0440\u0435\u0441\u0443\u0440\u0441\u0430" + case "vault_user_path": + return "\u043f\u0443\u0442\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0432 Vault" + case "vault_fields": + return "\u0441\u043f\u0438\u0441\u043e\u043a \u043a\u043b\u044e\u0447\u0435\u0439 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u0435\u043a\u0440\u0435\u0442\u043e\u0432" + default: + return "" + } +} + +func formatBoolTitle(value bool) string { + if value { + return "True" + } + return "False" +} + +func collectConstraints(p ParamSpec) string { + parts := []string{} + if p.MinValue != nil { + parts = append(parts, fmt.Sprintf("minvalue=%d", *p.MinValue)) + } + if p.MaxValue != nil { + parts = append(parts, fmt.Sprintf("maxvalue=%d", *p.MaxValue)) + } + if p.Regex != "" { + parts = append(parts, fmt.Sprintf("regex=%s", p.Regex)) + } + if len(p.ValueList) > 0 { + parts = append(parts, fmt.Sprintf("value_list=%s", strings.Join(p.ValueList, ", "))) + } + if p.Func != "" { + parts = append(parts, fmt.Sprintf("func=%s", p.Func)) + } + if p.RefSvcID != nil { + parts = append(parts, fmt.Sprintf("ref_svc_id=%d", *p.RefSvcID)) + } + if p.Unique != "" { + parts = append(parts, fmt.Sprintf("unique_scope=%s", p.Unique)) + } + if p.MaxLength != nil { + parts = append(parts, fmt.Sprintf("maxlength=%d", *p.MaxLength)) + } + if p.MinLength != nil { + parts = append(parts, fmt.Sprintf("minlength=%d", *p.MinLength)) + } + return strings.Join(parts, "; ") +} + +func escapeText(value string) string { + if value == "" { + return "" + } + text := html.EscapeString(value) + text = strings.ReplaceAll(text, "<br/>", "
") + text = strings.ReplaceAll(text, "<br />", "
") + text = strings.ReplaceAll(text, "<br>", "
") + return text +} + +func formatCode(code string) string { + if code == "" { + return "" + } + if len(code) <= 40 { + return "" + code + "" + } + out := []string{} + prev := rune(0) + for _, ch := range code { + if ch == '_' { + out = append(out, "_") + prev = ch + continue + } + if prev != 0 && (unicodeIsLower(prev) || unicodeIsDigit(prev)) && unicodeIsUpper(ch) { + out = append(out, "") + } + out = append(out, string(ch)) + prev = ch + } + return "" + strings.Join(out, "") + "" +} + +func formatParamCode(code string) string { + return formatCode(toSnake(code)) +} + +func toSnake(value string) string { + if value == "" { + return "" + } + var out []rune + lastUnderscore := false + prevLowerOrDigit := false + for _, r := range value { + if r >= 'A' && r <= 'Z' { + if prevLowerOrDigit && !lastUnderscore { + out = append(out, '_') + } + out = append(out, r+'a'-'A') + lastUnderscore = false + prevLowerOrDigit = false + continue + } + if (r >= 'a' && r <= 'z') || (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 + } + return strings.Trim(string(out), "_") +} + +func unicodeIsLower(r rune) bool { return r >= 'a' && r <= 'z' } +func unicodeIsUpper(r rune) bool { return r >= 'A' && r <= 'Z' } +func unicodeIsDigit(r rune) bool { return r >= '0' && r <= '9' } + +func stripHTML(value string) string { + if value == "" { + return "" + } + re := regexp.MustCompile("<[^>]+>") + cleaned := re.ReplaceAllString(value, " ") + cleaned = strings.Join(strings.Fields(cleaned), " ") + return html.UnescapeString(cleaned) +} + +func slug(value string) string { + out := []rune{} + for _, ch := range value { + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' { + out = append(out, ch) + } else if ch >= 'A' && ch <= 'Z' { + out = append(out, ch+'a'-'A') + } else { + out = append(out, '_') + } + } + return strings.Trim(string(out), "_") +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func escapeCode(value string) string { + buf := bytes.NewBuffer(nil) + for _, line := range strings.Split(value, "\n") { + buf.WriteString(html.EscapeString(line)) + buf.WriteString("\n") + } + return buf.String() +} + +// generateIndexMD writes index.md with the list of all processed services. +// The file is written to docsDir so each profile gets its own stand-specific index. +func generateIndexMD(docsDir string, specs []ServiceSpec) { + var b bytes.Buffer + b.WriteString("# Ресурсы провайдера\n\n") + b.WriteString("| ID | Ресурс | Описание |\n") + b.WriteString("|-----|--------|----------|\n") + for _, spec := range specs { + resName := "nubes_" + spec.Name + link := fmt.Sprintf("[%s](%s.md)", resName, spec.Name) + descr := strings.ReplaceAll(spec.ServiceDisplayName, "|", "\\|") + b.WriteString(fmt.Sprintf("| %d | %s | %s |\n", spec.ServiceID, link, descr)) + } + outPath := filepath.Join(docsDir, "index.md") + if err := os.WriteFile(outPath, b.Bytes(), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write index.md: %v\n", err) + } +} + +// capitalize делает первую букву строки заглавной. +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// findSubresourceParams возвращает параметры операции subresource по названию и action. +func findSubresourceParams(ops []OperationSpec, srName, action string) []ParamSpec { + for _, op := range ops { + if op.Kind == "subresource" && op.Subresource == srName && op.Action == action { + return op.Params + } + } + return nil +} + +// buildSubresourcePage генерирует страницу документации для subresource (nubes_{service}_{subresource}). +func buildSubresourcePage(spec ServiceSpec, srName string, nav string) string { + var b strings.Builder + srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, slug(srName)) + b.WriteString(fmt.Sprintf("# Resource %s\n\n", srResourceName)) + b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID)) + b.WriteString(nav + "\n\n") + + // Create params + createParams := findSubresourceParams(spec.Operations, srName, "create") + b.WriteString("## Create params\n\n") + if len(createParams) > 0 { + req, def := splitParams(createParams) + b.WriteString("**Обязательные параметры**\n\n") + b.WriteString(renderParamTable(req, true, true)) + if len(def) > 0 { + b.WriteString("\n**Параметры со значением по умолчанию**\n\n") + b.WriteString(renderParamTable(def, false, false)) + } + } else { + b.WriteString("None.\n") + } + + // Modify params (если есть) + modifyParams := findSubresourceParams(spec.Operations, srName, "modify") + if len(modifyParams) > 0 { + b.WriteString("\n## Modify params\n\n") + b.WriteString(renderModifyTable(modifyParams)) + } + + // Delete params + deleteParams := findSubresourceParams(spec.Operations, srName, "delete") + b.WriteString("\n## Delete params\n\n") + if len(deleteParams) > 0 { + b.WriteString(renderParamTable(deleteParams, true, true)) + } else { + b.WriteString("None.\n") + } + + return b.String() +} + +// buildSubresourceExamplePage генерирует страницу с HCL-примером для subresource. +func buildSubresourceExamplePage(spec ServiceSpec, srName string, nav string, version string) string { + var b strings.Builder + srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, slug(srName)) + b.WriteString(fmt.Sprintf("# Resource %s — Example\n\n", srResourceName)) + b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID)) + b.WriteString(nav + "\n\n") + b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_%s_main.tf`)\n\n", spec.Name, slug(srName))) + b.WriteString("```hcl\n") + + // terraform block + b.WriteString("terraform {\n") + b.WriteString(" required_providers {\n") + b.WriteString(" nubes = {\n") + b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n") + b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version)) + b.WriteString(" }\n") + b.WriteString(" }\n") + b.WriteString("}\n\n") + + b.WriteString("provider \"nubes\" {\n") + b.WriteString(" api_token = \"token_***\"\n") + b.WriteString(" api_endpoint = \"https://deck-api.ngcloud.ru/api/v1/index.cfm\"\n") + b.WriteString("}\n\n") + + // Основной ресурс (родитель) — ссылка + b.WriteString(fmt.Sprintf("# Родительский ресурс — nubes_%s должен быть создан заранее\n", spec.Name)) + b.WriteString(fmt.Sprintf("# resource \"nubes_%s\" \"baza\" { ... }\n\n", spec.Name)) + + // Subresource + b.WriteString(fmt.Sprintf("resource \"%s\" \"example\" {\n", srResourceName)) + b.WriteString(fmt.Sprintf(" %s_id = nubes_%s.baza.id\n", spec.Name, spec.Name)) + + createParams := findSubresourceParams(spec.Operations, srName, "create") + req, def := splitParams(createParams) + for _, p := range req { + b.WriteString(formatParamLine(p, " ", true)) + } + if len(def) > 0 { + b.WriteString("\n # Параметры со значением по умолчанию\n") + for _, p := range def { + b.WriteString(formatParamLine(p, " ", false)) + } + } + + b.WriteString("}\n") + b.WriteString("```\n") + return b.String() +} diff --git a/universal_rebuild/tools/gen/README.md b/universal_rebuild/tools/gen/README.md new file mode 100644 index 0000000..224af6f --- /dev/null +++ b/universal_rebuild/tools/gen/README.md @@ -0,0 +1,6 @@ +# Generator (placeholder) + +Этот каталог будет содержать генератор: +- читает resources_yaml/* +- генерирует thin ресурсы в internal/resources_gen +- обновляет registry.go diff --git a/universal_rebuild/tools/gen/generate_resources.go b/universal_rebuild/tools/gen/generate_resources.go new file mode 100644 index 0000000..43dfeab --- /dev/null +++ b/universal_rebuild/tools/gen/generate_resources.go @@ -0,0 +1,996 @@ +package main + +import ( + "bytes" + "fmt" + "go/format" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "text/template" + "unicode" + + "gopkg.in/yaml.v3" +) + +type Param struct { + ID int `yaml:"id"` + Code string `yaml:"code"` + Type string `yaml:"type"` + Required bool `yaml:"required"` + Default string `yaml:"default"` + // NOTE(name-to-uuid): ref_svc_id marks params that accept UI names, resolved in core. + RefSvcId int `yaml:"ref_svc_id,omitempty"` +} + +type OutputParam struct { + Code string `yaml:"code"` + Type string `yaml:"type"` + Sensitive bool `yaml:"sensitive,omitempty"` +} + +type ServiceYAML struct { + Name string `yaml:"name"` + ServiceID int `yaml:"service_id"` + Create struct { + Params []Param `yaml:"params"` + } `yaml:"create"` + Modify struct { + Params []Param `yaml:"params"` + } `yaml:"modify"` + Outputs struct { + Params []OutputParam `yaml:"params"` + } `yaml:"outputs"` + Lifecycle struct { + DeleteModeDefault string `yaml:"delete_mode_default"` + ResumeIfExistsDefault bool `yaml:"resume_if_exists_default"` + } `yaml:"lifecycle"` +} + +type GenResource struct { + Name string + ServiceID int + CreateParams []Param + CreateFixedParams []FixedParam + ModifyParams []Param + SchemaParams []Param + OutputParams []OutputParam + DeleteMode string + ResumeIfExists bool + + UsesBool bool + UsesInt64 bool + UsesString bool + HasDefaults bool + NeedsBoolDefault bool + NeedsInt64Default bool + NeedsStringDefault bool +} + +func main() { + root, err := os.Getwd() + if err != nil { + panic(err) + } + resourcesDir := filepath.Join(root, "resources_yaml") + extraParamsDir := filepath.Join(root, "extra-params") + outDir := filepath.Join(root, "internal", "resources_gen") + + services, err := loadServices(resourcesDir, extraParamsDir) + if err != nil { + panic(err) + } + + for _, svc := range services { + if err := writeResource(outDir, svc); err != nil { + panic(err) + } + } + if err := writeRegistry(outDir, services); err != nil { + panic(err) + } +} + +func loadServices(dir string, extraDir string) ([]GenResource, error) { + var services []GenResource + 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 svc ServiceYAML + if err := yaml.Unmarshal(b, &svc); err != nil { + return err + } + + createParams, createFixed := splitCreateParams(svc.Name, svc.Create.Params) + modifyParams := svc.Modify.Params + modifyParams = normalizeModifyParams(createParams, modifyParams) + schemaParams, err := buildSchemaParams(extraDir, svc.ServiceID, createParams, modifyParams) + if err != nil { + return err + } + outputParams := mergeOutputParams(commonOutputParams(), normalizeOutputParams(svc.Outputs.Params)) + gr := GenResource{ + Name: svc.Name, + ServiceID: svc.ServiceID, + CreateParams: createParams, + CreateFixedParams: createFixed, + ModifyParams: modifyParams, + SchemaParams: schemaParams, + OutputParams: outputParams, + DeleteMode: svc.Lifecycle.DeleteModeDefault, + ResumeIfExists: svc.Lifecycle.ResumeIfExistsDefault, + } + + analyzeParams := func(p Param) { + switch strings.ToLower(p.Type) { + case "bool": + gr.UsesBool = true + if p.Default != "" && !p.Required { + gr.NeedsBoolDefault = true + gr.HasDefaults = true + } + case "int", "int64", "number": + gr.UsesInt64 = true + if p.Default != "" && !p.Required { + gr.NeedsInt64Default = true + gr.HasDefaults = true + } + default: + gr.UsesString = true + if p.Default != "" && !p.Required { + gr.NeedsStringDefault = true + gr.HasDefaults = true + } + } + } + for _, p := range gr.SchemaParams { + analyzeParams(p) + } + + // Schema defaults apply only to create params + gr.NeedsBoolDefault = false + gr.NeedsInt64Default = false + gr.NeedsStringDefault = false + for _, p := range gr.SchemaParams { + if p.Default == "" || p.Required { + continue + } + switch strings.ToLower(p.Type) { + case "bool": + gr.NeedsBoolDefault = true + case "int", "int64", "number": + gr.NeedsInt64Default = true + default: + gr.NeedsStringDefault = true + } + } + + services = append(services, gr) + return nil + }) + + if walkErr != nil { + return nil, walkErr + } + + sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) + return services, nil +} + +func writeResource(outDir string, svc 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": toCamel, + "ToSnake": toSnake, + "ParamType": paramType, + "ParamDefault": paramDefault, + "ParamDefaultExpr": paramDefaultExpr, + "ParamFormat": paramFormat, + "ParamParse": paramParse, + "ParamDescription": paramDescription, + "OutputType": outputParamType, + "OutputIsMap": outputParamIsMap, + "OutputIsList": outputParamIsList, + "OutputSensitive": outputParamSensitive, + "FixedParamExpr": fixedParamExpr, + "bt": func() string { return "`" }, + }).Parse(resourceTemplate) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, svc); err != nil { + return err + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + formatted = buf.Bytes() + } + + return os.WriteFile(filePath, formatted, 0644) +} + +func writeRegistry(outDir string, services []GenResource) 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. 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", toCamel(svc.Name))) + } + buf.WriteString("\t}\n") + buf.WriteString("}\n") + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + formatted = buf.Bytes() + } + + return os.WriteFile(filepath.Join(outDir, "registry.go"), formatted, 0644) +} + +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) +} + +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) +} + +func paramType(p Param) string { + switch strings.ToLower(p.Type) { + case "bool": + return "types.Bool" + case "int", "int64", "number": + return "types.Int64" + default: + return "types.String" + } +} + +func paramDefault(p Param) string { + return p.Default +} + +func ensureGoIdent(s string) string { + if s == "" { + return "R" + } + first := rune(s[0]) + if unicode.IsLetter(first) || first == '_' { + return s + } + return "R" + s +} + +func paramDefaultExpr(p 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) + } +} + +func outputParamType(p OutputParam) string { + switch strings.ToLower(p.Type) { + case "map": + return "types.Map" + case "list": + return "types.List" + default: + return "types.String" + } +} + +func outputParamIsMap(p OutputParam) bool { + return strings.EqualFold(strings.TrimSpace(p.Type), "map") +} + +func outputParamIsList(p OutputParam) bool { + return strings.EqualFold(strings.TrimSpace(p.Type), "list") +} + +func outputParamSensitive(p OutputParam) bool { + return p.Sensitive +} + +func paramFormat(p 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) + } +} + +func paramDescription(p Param) string { + if p.RefSvcId <= 0 { + return "" + } + // refSvcId marks name-based references that must be resolved to UUID in core. + return fmt.Sprintf("\"UI display_name for service_id=%d (mapped to UUID in core)\"", p.RefSvcId) +} + +const resourceTemplate = `package resources_gen + +import ( + "context" + + "terraform-provider-nubes/internal/core" + "terraform-provider-nubes/internal/resources_core" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + {{- if .NeedsInt64Default }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + {{- end }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Code generated by tools/gen. DO NOT EDIT. +// Service: {{.Name}} +// Service ID: {{.ServiceID}} + +var _ resource.Resource = &{{ToCamel .Name}}Resource{} +var _ resource.ResourceWithModifyPlan = &{{ToCamel .Name}}Resource{} + +type {{ToCamel .Name}}Resource struct { + client *core.UniversalClient +} + +type {{ToCamel .Name}}Model struct { + ID types.String {{bt}}tfsdk:"id"{{bt}} + ResourceName types.String {{bt}}tfsdk:"resource_name"{{bt}} +{{- range .SchemaParams }} + {{ToCamel .Code}} {{ParamType .}} {{bt}}tfsdk:"{{ToSnake .Code}}"{{bt}} +{{- end }} + DeleteMode types.String {{bt}}tfsdk:"delete_mode"{{bt}} + ResumeIfExists types.Bool {{bt}}tfsdk:"resume_if_exists"{{bt}} +{{- range .OutputParams }} + {{ToCamel .Code}} {{OutputType .}} {{bt}}tfsdk:"{{ToSnake .Code}}"{{bt}} +{{- end }} +} + +func New{{ToCamel .Name}}Resource() resource.Resource { + return &{{ToCamel .Name}}Resource{} +} + +func (r *{{ToCamel .Name}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_{{.Name}}" +} + +func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + attrs := map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true}, + "resource_name": schema.StringAttribute{Required: true}, +{{- range .SchemaParams }} + "{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{ + {{if .Required}}Required: true,{{else}}Optional: true,{{end}} + {{if and (ParamDefault .) (not .Required)}}Computed: true,{{end}} + {{if and (ParamDefault .) (not .Required)}}Default: {{ParamDefaultExpr .}},{{end}} + {{- if ParamDescription . }}MarkdownDescription: {{ParamDescription .}},{{end}} + }, +{{- end }} + "delete_mode": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("{{if .DeleteMode}}{{.DeleteMode}}{{else}}state_only{{end}}"), + }, + "resume_if_exists": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool({{if .ResumeIfExists}}true{{else}}false{{end}}), + }, +{{- range .OutputParams }} + "{{ToSnake .Code}}": schema.{{if OutputIsMap .}}Map{{else if OutputIsList .}}List{{else}}String{{end}}Attribute{ + Computed: true, + {{- if or (OutputIsMap .) (OutputIsList .) }}ElementType: types.StringType,{{- end }} + {{- if OutputSensitive . }}Sensitive: true,{{- end }} + }, +{{- end }} + } + + resp.Schema = schema.Schema{Attributes: attrs} +} + +func (r *{{ToCamel .Name}}Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if r.client == nil { + return + } + + var config *{{ToCamel .Name}}Model + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + if config == nil { + return + } + + var state *{{ToCamel .Name}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if state != nil && !state.ID.IsNull() && !state.ID.IsUnknown() { + return + } + + if config.ResourceName.IsNull() || config.ResourceName.IsUnknown() { + return + } + resumeIfExists := true + if !config.ResumeIfExists.IsNull() && !config.ResumeIfExists.IsUnknown() { + resumeIfExists = config.ResumeIfExists.ValueBool() + } + + resp.Diagnostics.Append(resources_core.PlanExistingResourceDiagnostics(ctx, r.client, {{.ServiceID}}, config.ResourceName.ValueString(), resumeIfExists)...) +} + +func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data {{ToCamel .Name}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resourceName := data.ResourceName.ValueString() + resp.Diagnostics.Append(resources_core.CreateExistingResourceDiagnostics(ctx, r.client, {{.ServiceID}}, resourceName, data.ResumeIfExists.ValueBool())...) + + params := map[int]string{ +{{- range .CreateFixedParams }} + {{.ID}}: {{FixedParamExpr .}}, +{{- end }} +{{- range .CreateParams }} + {{.ID}}: {{ParamFormat . (printf "data.%s" (ToCamel .Code))}}, +{{- end }} + } + + id, err := resources_core.CreateResource(ctx, r.client, {{.ServiceID}}, resourceName, data.ResumeIfExists.ValueBool(), params) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + data.ID = types.StringValue(id) + if r.client != nil { + outputs, outDiags := resources_core.FetchInstanceOutputs(ctx, r.client, data.ID.ValueString()) + resp.Diagnostics.Append(outDiags...) + if resp.Diagnostics.HasError() { + return + } +{{- range .OutputParams }} + data.{{ToCamel .Code}} = outputs.{{ToCamel .Code}} +{{- end }} +{{- if .SchemaParams }} + // Sync input params from state_params to avoid drift (ex: git_path). + paramsMap, paramsDiags := resources_core.StringMapFromTypesMap(ctx, outputs.StateParams) + resp.Diagnostics.Append(paramsDiags...) + if resp.Diagnostics.HasError() { + return + } + // Map refSvcId UUIDs back to display_name for stable state. + paramsMap, paramsDiags = resources_core.ResolveRefSvcParamDisplayNames(ctx, r.client, {{.ServiceID}}, paramsMap) + resp.Diagnostics.Append(paramsDiags...) + if resp.Diagnostics.HasError() { + return + } +{{- range .SchemaParams }} + if val, ok := paramsMap["{{.Code}}"] ; ok { + data.{{ToCamel .Code}} = {{ParamParse .}}(val) + } +{{- end }} +{{- end }} + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *{{ToCamel .Name}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *{{ToCamel .Name}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + if data == nil { + return + } + if r.client != nil && !data.ID.IsNull() && !data.ID.IsUnknown() { + remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if remove { + resp.State.RemoveResource(ctx) + return + } + outputs, outDiags := resources_core.FetchInstanceOutputs(ctx, r.client, data.ID.ValueString()) + resp.Diagnostics.Append(outDiags...) + if resp.Diagnostics.HasError() { + return + } +{{- range .OutputParams }} + data.{{ToCamel .Code}} = outputs.{{ToCamel .Code}} +{{- end }} +{{- if .SchemaParams }} + // Sync input params from state_params to avoid drift (ex: git_path). + paramsMap, paramsDiags := resources_core.StringMapFromTypesMap(ctx, outputs.StateParams) + resp.Diagnostics.Append(paramsDiags...) + if resp.Diagnostics.HasError() { + return + } + // Map refSvcId UUIDs back to display_name for stable state. + paramsMap, paramsDiags = resources_core.ResolveRefSvcParamDisplayNames(ctx, r.client, {{.ServiceID}}, paramsMap) + resp.Diagnostics.Append(paramsDiags...) + if resp.Diagnostics.HasError() { + return + } +{{- range .SchemaParams }} + if val, ok := paramsMap["{{.Code}}"] ; ok { + data.{{ToCamel .Code}} = {{ParamParse .}}(val) + } +{{- end }} +{{- end }} + } + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) +} + +func (r *{{ToCamel .Name}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan {{ToCamel .Name}}Model + var state {{ToCamel .Name}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + instanceID := state.ID + if instanceID.IsNull() || instanceID.IsUnknown() { + instanceID = plan.ID + } + if instanceID.IsNull() || instanceID.IsUnknown() { + resp.Diagnostics.AddError("Ошибка клиента", "отсутствует id инстанса для изменения") + return + } + + params := map[int]string{ +{{- range .ModifyParams }} + {{.ID}}: {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, +{{- end }} + } + + if err := resources_core.UpdateResource(ctx, r.client, instanceID.ValueString(), params); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = instanceID + if r.client != nil && !instanceID.IsNull() && !instanceID.IsUnknown() { + outputs, outDiags := resources_core.FetchInstanceOutputs(ctx, r.client, instanceID.ValueString()) + resp.Diagnostics.Append(outDiags...) + if resp.Diagnostics.HasError() { + return + } +{{- range .OutputParams }} + plan.{{ToCamel .Code}} = outputs.{{ToCamel .Code}} +{{- end }} +{{- if .SchemaParams }} + // Sync input params from state_params to avoid drift (ex: git_path). + paramsMap, paramsDiags := resources_core.StringMapFromTypesMap(ctx, outputs.StateParams) + resp.Diagnostics.Append(paramsDiags...) + if resp.Diagnostics.HasError() { + return + } + // Map refSvcId UUIDs back to display_name for stable state. + paramsMap, paramsDiags = resources_core.ResolveRefSvcParamDisplayNames(ctx, r.client, {{.ServiceID}}, paramsMap) + resp.Diagnostics.Append(paramsDiags...) + if resp.Diagnostics.HasError() { + return + } +{{- range .SchemaParams }} + if val, ok := paramsMap["{{.Code}}"] ; ok { + plan.{{ToCamel .Code}} = {{ParamParse .}}(val) + } +{{- end }} +{{- end }} + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *{{ToCamel .Name}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if state == nil || state.ID.IsNull() || state.ID.IsUnknown() { + return + } + + if err := resources_core.DeleteResource(ctx, r.client, state.ID.ValueString(), state.DeleteMode.ValueString()); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } +} + +func (r *{{ToCamel .Name}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient") + return + } + + r.client = client +} + +// format helpers live in resources_core/helpers.go +` + +type FixedParam struct { + ID int + Value string +} + +func splitCreateParams(serviceName string, params []Param) ([]Param, []FixedParam) { + if len(params) == 0 { + return nil, nil + } + return params, nil +} + +func isResourceRealmParam(p Param) bool { + return strings.EqualFold(strings.TrimSpace(p.Code), "resourceRealm") +} + +func fixedParamExpr(p FixedParam) string { + return fmt.Sprintf("resources_core.FormatString(types.StringValue(%q))", p.Value) +} + +func mergeParams(createParams []Param, modifyParams []Param) []Param { + if len(createParams) == 0 && len(modifyParams) == 0 { + return nil + } + + byCode := make(map[string]Param) + order := make([]string, 0, len(createParams)+len(modifyParams)) + + for _, p := range createParams { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + continue + } + byCode[key] = p + order = append(order, key) + } + + for _, p := range modifyParams { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + continue + } + if _, exists := byCode[key]; !exists { + p.Required = false + byCode[key] = p + order = append(order, key) + } + } + + merged := make([]Param, 0, len(order)) + seen := make(map[string]struct{}, len(order)) + for _, key := range order { + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + merged = append(merged, byCode[key]) + } + + return merged +} + +func normalizeModifyParams(createParams []Param, modifyParams []Param) []Param { + if len(modifyParams) == 0 { + return nil + } + + createByCode := make(map[string]Param) + for _, p := range createParams { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + continue + } + createByCode[key] = p + } + + normalized := make([]Param, 0, len(modifyParams)) + for _, p := range modifyParams { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + continue + } + if base, ok := createByCode[key]; ok { + p.Type = base.Type + } + normalized = append(normalized, p) + } + + return normalized +} + +// ===== extra params support ===== + +type ExtraParamSpec struct { + Type string `yaml:"type"` + Required bool `yaml:"required"` +} + +func buildSchemaParams(extraDir string, serviceID int, createParams []Param, modifyParams []Param) ([]Param, error) { + base := mergeParams(createParams, modifyParams) + extra, err := loadExtraParams(extraDir, serviceID) + if err != nil { + return nil, err + } + if len(extra) == 0 { + return base, nil + } + return mergeExtraParams(base, extra) +} + +func loadExtraParams(extraDir string, serviceID int) ([]Param, error) { + if extraDir == "" { + return nil, nil + } + entries, err := os.ReadDir(extraDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var params []Param + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + fileSvcID, err := parseServiceID(entry.Name()) + if err != nil { + return nil, err + } + if fileSvcID != serviceID { + continue + } + path := filepath.Join(extraDir, entry.Name()) + fileParams, err := parseExtraParamsFile(path) + if err != nil { + return nil, err + } + params = append(params, fileParams...) + } + + return params, nil +} + +func parseExtraParamsFile(path string) ([]Param, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var raw []map[string]ExtraParamSpec + if err := yaml.Unmarshal(b, &raw); err != nil { + return nil, fmt.Errorf("invalid extra params yaml %s: %w", path, err) + } + if len(raw) == 0 { + return nil, nil + } + + params := make([]Param, 0, len(raw)) + for _, item := range raw { + if len(item) != 1 { + return nil, fmt.Errorf("invalid extra params entry in %s: expected single key", path) + } + for code, spec := range item { + code = strings.TrimSpace(code) + if code == "" { + return nil, fmt.Errorf("invalid extra params entry in %s: empty code", path) + } + paramType := normalizeExtraType(spec.Type) + params = append(params, Param{ + Code: code, + Type: paramType, + Required: spec.Required, + }) + } + } + + return params, nil +} + +func normalizeExtraType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "string" + } + switch value { + case "bool", "boolean": + return "bool" + case "int", "int64", "number": + return "int64" + default: + return "string" + } +} + +func mergeExtraParams(base []Param, extra []Param) ([]Param, error) { + if len(extra) == 0 { + return base, nil + } + byCode := make(map[string]struct{}, len(base)) + for _, p := range base { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + continue + } + byCode[key] = struct{}{} + } + + for _, p := range extra { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + return nil, fmt.Errorf("invalid extra param: empty code") + } + if _, exists := byCode[key]; exists { + return nil, fmt.Errorf("extra param %q conflicts with existing API param", p.Code) + } + byCode[key] = struct{}{} + base = append(base, p) + } + + return base, nil +} + +func normalizeOutputParams(params []OutputParam) []OutputParam { + normalized := make([]OutputParam, 0, len(params)) + for _, p := range params { + code := strings.TrimSpace(p.Code) + if code == "" { + continue + } + p.Code = code + p.Type = normalizeOutputType(p.Type) + normalized = append(normalized, p) + } + return normalized +} + +func normalizeOutputType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "string" + } + switch value { + case "map", "list", "string": + return value + default: + return "string" + } +} + +func commonOutputParams() []OutputParam { + 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 paramParse(p 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" + } +} + +func mergeOutputParams(base []OutputParam, extra []OutputParam) []OutputParam { + if len(extra) == 0 { + return base + } + byCode := make(map[string]struct{}, len(base)) + for _, p := range base { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + continue + } + byCode[key] = struct{}{} + } + + for _, p := range extra { + key := strings.ToLower(strings.TrimSpace(p.Code)) + if key == "" { + continue + } + if _, exists := byCode[key]; exists { + continue + } + byCode[key] = struct{}{} + base = append(base, p) + } + + return base +} + +func parseServiceID(name string) (int, error) { + trimmed := strings.TrimSpace(name) + var digits []rune + for _, r := range trimmed { + if r < '0' || r > '9' { + break + } + digits = append(digits, r) + } + if len(digits) == 0 { + return 0, fmt.Errorf("extra params file %q must start with service id", name) + } + value, err := strconv.Atoi(string(digits)) + if err != nil { + return 0, fmt.Errorf("invalid service id in extra params file %q", name) + } + return value, nil +} diff --git a/universal_rebuild/tools/gen_v2/generate_resources_v2.go b/universal_rebuild/tools/gen_v2/generate_resources_v2.go new file mode 100644 index 0000000..886c5ea --- /dev/null +++ b/universal_rebuild/tools/gen_v2/generate_resources_v2.go @@ -0,0 +1,2164 @@ +package main + +import ( + "bytes" + "fmt" + "go/format" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + "unicode" + + "gopkg.in/yaml.v3" +) + +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"` + RefSvcId *int `yaml:"ref_svc_id,omitempty"` + Descr string `yaml:"descr,omitempty"` + Man string `yaml:"man,omitempty"` + Sensitive bool `yaml:"is_sensitive,omitempty"` +} + +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 ServiceSpec struct { + Name string `yaml:"name"` + ServiceID int `yaml:"service_id"` + 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"` +} + +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 +} + +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 + // NeedsJsonPlanMod: хотя бы один атрибут имеет data_type: json. + // Управляет добавлением импорта planmodifier в сгенерированный файл. + NeedsJsonPlanMod bool + // NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update). + NeedsStringsImport bool +} + +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: хотя бы один атрибут имеет IsJson=true. + NeedsJsonPlanMod bool + // NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update). + NeedsStringsImport bool +} + +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: хотя бы один атрибут имеет IsJson=true. + NeedsJsonPlanMod bool + // NeedsStringsImport: есть строковые SchemaParams (нужен EqualFold). + NeedsStringsImport bool +} + +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 := loadSpecs(resourcesDir) + if err != nil { + panic(err) + } + + for _, svc := range instanceResources { + if err := writeInstanceResource(outDir, svc); err != nil { + panic(err) + } + } + for _, sr := range subresources { + if err := writeSubresource(outDir, sr); err != nil { + panic(err) + } + } + for _, act := range actions { + if err := writeActionResource(outDir, act); err != nil { + panic(err) + } + } + if err := writeRegistry(outDir, instanceResources, subresources, actions); err != nil { + panic(err) + } +} + +func loadSpecs(dir string) ([]GenResource, []GenSubresource, []GenAction, error) { + var services []GenResource + var subs []GenSubresource + var actions []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 ServiceSpec + if err := yaml.Unmarshal(b, &spec); err != nil { + return err + } + + createParams := []Param{} + modifyParams := []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 := mergeParams(createParams, modifyParams) + createParams = alignParamTypes(createParams, schemaParams) + modifyParams = alignParamTypes(modifyParams, schemaParams) + createOnly := computeCreateOnly(createParams, modifyParams) + schemaParams = 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 := GenResource{ + Name: spec.Name, + ServiceID: spec.ServiceID, + CreateParams: createParams, + ModifyParams: modifyParams, + SchemaParams: schemaParams, + CreateOnlyParams: filterCreateOnly(schemaParams), + CreateOnlyRequiredParams: filterCreateOnlyRequired(schemaParams), + OutputParams: normalizeOutputParams(spec.Outputs.Params), + HasRefSvcParams: hasRefSvcParams(schemaParams), + SupportsSuspendDestroy: supportsSuspendDestroy, + SuspendOnDestroy: suspendOnDestroy, + AdoptExistingOnCreate: adoptExistingOnCreate, + HasDomainParam: hasDomainParam, + } + analyzeParams(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) + gr.NeedsJsonPlanMod = analyzeJsonPlanMod(gr.SchemaParams) + // NeedsStringsImport = true если: + // 1) есть ref_svc params (RefSvcId > 0, != 12) — шаблон генерирует restore-casing блоки с strings.EqualFold + // 2) есть строковые ModifyParams — шаблон генерирует EqualFold в hasServiceParamChanges (Update) + // 3) есть строковые CreateOnlyParams — шаблон генерирует EqualFold в create-only validation (ModifyPlan) + // RefSvcId=12 (S3 Object Storage) НЕ генерирует restore-блок — для S3 UUID обязателен точный формат + gr.NeedsStringsImport = hasRestoreCasingParams(gr.SchemaParams) || analyzeNeedsStrings(gr.ModifyParams) || analyzeNeedsStrings(gr.CreateOnlyRequiredParams) || analyzeNeedsStrings(gr.CreateOnlyParams) + services = append(services, gr) + + srByName := map[string]*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 = &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 = mergeParams(sr.CreateParams, sr.ModifyParams, sr.DeleteParams) + sr.CreateParams = alignParamTypes(sr.CreateParams, sr.SchemaParams) + sr.ModifyParams = alignParamTypes(sr.ModifyParams, sr.SchemaParams) + sr.DeleteParams = alignParamTypes(sr.DeleteParams, sr.SchemaParams) + createOnly := computeCreateOnly(sr.CreateParams, sr.ModifyParams) + sr.SchemaParams = markCreateOnly(sr.SchemaParams, createOnly) + forceNewCodes := buildSubresourceForceNewCodes(sr, createOnly) + sr.SchemaParams = markForceNew(sr.SchemaParams, forceNewCodes) + sr.IdentityParams = filterForceNew(sr.SchemaParams) + if len(sr.IdentityParams) == 0 { + sr.IdentityParams = sr.SchemaParams + } + sr.HasRefSvcParams = hasRefSvcParams(sr.SchemaParams) + analyzeParams(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams) + analyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams) + sr.NeedsJsonPlanMod = analyzeJsonPlanMod(sr.SchemaParams) + sr.NeedsStringsImport = analyzeNeedsStrings(sr.ModifyParams) + subs = append(subs, *sr) + } + + for _, op := range spec.Operations { + if op.Kind != "action" { + continue + } + act := GenAction{ + ServiceName: spec.Name, + ServiceID: spec.ServiceID, + ActionName: op.Action, + OperationName: op.Name, + Params: convertParams(op.Params), + } + act.SchemaParams = act.Params + analyzeParams(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams) + act.NeedsJsonPlanMod = analyzeJsonPlanMod(act.SchemaParams) + act.NeedsStringsImport = analyzeNeedsStrings(act.SchemaParams) + actions = append(actions, act) + } + + 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 +} + +func convertParams(params []ParamSpec) []Param { + out := make([]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, 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: флаг для добавления JsonNormalize план-модификатора в схему. + IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"), + }) + } + return out +} + +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" +} + +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) + } +} + +func normalizeOutputParams(params []OutputParam) []OutputParam { + if len(params) == 0 { + return []OutputParam{} + } + return params +} + +func hasRefSvcParams(params []Param) bool { + for _, p := range params { + if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") { + return true + } + } + return false +} + +func mergeParams(sets ...[]Param) []Param { + seen := map[string]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([]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 +} + +func alignParamTypes(params []Param, schema []Param) []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 +} + +func computeCreateOnly(createParams []Param, modifyParams []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 +} + +func markCreateOnly(schema []Param, createOnly map[string]struct{}) []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 +} + +func filterCreateOnly(schema []Param) []Param { + if len(schema) == 0 { + return []Param{} + } + + result := make([]Param, 0, len(schema)) + for _, p := range schema { + if p.CreateOnly { + result = append(result, p) + } + } + return result +} + +func filterCreateOnlyRequired(schema []Param) []Param { + if len(schema) == 0 { + return []Param{} + } + + result := make([]Param, 0, len(schema)) + for _, p := range schema { + if p.CreateOnly && p.Required { + result = append(result, p) + } + } + return result +} + +func analyzeParams(usesBool *bool, usesInt64 *bool, usesString *bool, hasDefaults *bool, needsBoolDefault *bool, needsInt64Default *bool, needsStringDefault *bool, params []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 + } + } + } +} + +func writeInstanceResource(outDir string, svc 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": toCamel, + "ToSnake": toSnake, + "ParamType": paramType, + "ParamDefaultExpr": paramDefaultExpr, + "ParamFormat": paramFormat, + "ParamParse": paramParse, + "ParamDescription": paramDescription, + "OutputType": outputParamType, + "OutputIsMap": outputParamIsMap, + "OutputIsList": outputParamIsList, + "OutputSensitive": outputParamSensitive, + "bt": func() string { return "`" }, + }).Parse(instanceTemplate) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, svc); err != nil { + return err + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + formatted = buf.Bytes() + } + + return os.WriteFile(filePath, formatted, 0644) +} + +func writeSubresource(outDir string, sr 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": toCamel, + "ToSnake": toSnake, + "ParamType": paramType, + "ParamDefaultExpr": paramDefaultExpr, + "ParamFormat": paramFormat, + "ParamParse": paramParse, + "ParamDescription": paramDescription, + "PlanModifierType": planModifierType, + "PlanModifierExpr": planModifierExpr, + "bt": func() string { return "`" }, + }).Parse(subresourceTemplate) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, sr); err != nil { + return err + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + formatted = buf.Bytes() + } + + return os.WriteFile(filePath, formatted, 0644) +} + +func writeActionResource(outDir string, act 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": toCamel, + "ToSnake": toSnake, + "ParamType": paramType, + "ParamDefaultExpr": paramDefaultExpr, + "ParamFormat": paramFormat, + "ParamParse": paramParse, + "ParamDescription": paramDescription, + "bt": func() string { return "`" }, + }).Parse(actionTemplate) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, act); err != nil { + return err + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + formatted = buf.Bytes() + } + + return os.WriteFile(filePath, formatted, 0644) +} + +func writeRegistry(outDir string, services []GenResource, subs []GenSubresource, actions []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", toCamel(svc.Name))) + } + for _, sr := range subs { + buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", toCamel(sr.ServiceName+"_"+sr.SubName))) + } + for _, act := range actions { + buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", toCamel(act.ServiceName+"_"+act.ActionName))) + } + buf.WriteString("\t}\n") + buf.WriteString("}\n") + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + formatted = buf.Bytes() + } + + return os.WriteFile(filepath.Join(outDir, "registry.go"), formatted, 0644) +} + +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) +} + +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) +} + +func paramType(p Param) string { + switch strings.ToLower(p.Type) { + case "bool": + return "types.Bool" + case "int", "int64", "number": + return "types.Int64" + default: + return "types.String" + } +} + +func paramDefaultExpr(p 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) + } +} + +func paramFormat(p 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) + } +} + +func paramParse(p 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" + } +} + +func paramDescription(p 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) +} + +func outputParamType(p OutputParam) string { + switch strings.ToLower(p.Type) { + case "map": + return "types.Map" + case "list": + return "types.List" + default: + return "types.String" + } +} + +func outputParamIsMap(p OutputParam) bool { + return strings.EqualFold(strings.TrimSpace(p.Type), "map") +} + +func outputParamIsList(p OutputParam) bool { + return strings.EqualFold(strings.TrimSpace(p.Type), "list") +} + +func outputParamSensitive(p OutputParam) bool { + return p.Sensitive +} + +func ensureGoIdent(s string) string { + if s == "" { + return "R" + } + first := rune(s[0]) + if unicode.IsLetter(first) || first == '_' { + return s + } + return "R" + s +} + +const instanceTemplate = `package resources_gen + +import ( + "context" + {{- if .NeedsStringsImport }} + "strings" + {{- end }} + + "terraform-provider-nubes/internal/core" + "terraform-provider-nubes/internal/resources_core" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + {{- if .NeedsInt64Default }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + {{- end }} + {{- if .NeedsStringDefault }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + {{- end }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Code generated by tools/gen_v2. DO NOT EDIT. +// Service: {{.Name}} +// Service ID: {{.ServiceID}} + +var _ resource.Resource = &{{ToCamel .Name}}Resource{} +var _ resource.ResourceWithModifyPlan = &{{ToCamel .Name}}Resource{} +var _ resource.ResourceWithImportState = &{{ToCamel .Name}}Resource{} + +type {{ToCamel .Name}}Resource struct { + client *core.UniversalClient +} + +type {{ToCamel .Name}}Model struct { + ID types.String ` + "`" + `tfsdk:"id"` + "`" + ` + ResourceName types.String ` + "`" + `tfsdk:"resource_name"` + "`" + ` + OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + ` + LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + ` + {{- range .SchemaParams }} + {{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` + {{- end }} + SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + ` + AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` + {{- range .OutputParams }} + {{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` + {{- end }} +} + +func New{{ToCamel .Name}}Resource() resource.Resource { + return &{{ToCamel .Name}}Resource{} +} + +func (r *{{ToCamel .Name}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_{{.Name}}" +} + +func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + attrs := map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "resource_name": schema.StringAttribute{Required: true}, + "operation_timeout": schema.StringAttribute{Optional: true}, + "log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."}, + {{- range .SchemaParams }} + "{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{ + {{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}} + {{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }} + {{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}} + {{- if .Sensitive }}Sensitive: true,{{end}} + {{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{- end }} + }, + {{- end }} + "suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})}, + "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})}, + {{- range .OutputParams }} + {{- if or (OutputIsMap .) (OutputIsList .) }} + "{{ToSnake .Code}}": schema.{{if OutputIsMap .}}Map{{else}}List{{end}}Attribute{Computed: true, ElementType: types.StringType{{if OutputSensitive .}}, Sensitive: true{{end}}}, + {{- else }} + "{{ToSnake .Code}}": schema.StringAttribute{Computed: true{{if OutputSensitive .}}, Sensitive: true{{end}}}, + {{- end }} + {{- end }} + } + + resp.Schema = schema.Schema{Attributes: attrs} +} + +func (r *{{ToCamel .Name}}Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if r.client == nil { + return + } + + var config *{{ToCamel .Name}}Model + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + if config == nil { + return + } + + var state *{{ToCamel .Name}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if req.State.Raw.IsNull() && req.Plan.Raw.IsNull() { + return + } + + if state != nil && !state.ID.IsNull() && !state.ID.IsUnknown() { + var plan {{ToCamel .Name}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + {{- if .HasRefSvcParams }} + // FIX(uuid-case): resolve ref_svc params НЕ делаем в ModifyPlan. + // Terraform правило: plan ОБЯЗАН равняться config для user-provided атрибутов. + // Resolve (displayName → UUID или uppercase → lowercase) нужен только для + // API-вызова в Create/Update. Делать его здесь = менять plan = ошибка + // "Provider produced invalid plan: planned value does not match config value". + {{- end }} + if !plan.ResourceName.IsNull() && !plan.ResourceName.IsUnknown() && !state.ResourceName.IsNull() && !state.ResourceName.IsUnknown() { + if plan.ResourceName.ValueString() != state.ResourceName.ValueString() { + resp.Diagnostics.AddError("Нельзя изменить resource_name", "Параметр resource_name задается при создании и не может быть изменен. Создайте новый ресурс с другим именем.") + return + } + } + {{- range .CreateOnlyParams }} + if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { + {{- if eq (ParamType .) "types.Bool" }} + if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() { + resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.") + return + } + {{- else if eq (ParamType .) "types.Int64" }} + if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() { + resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.") + return + } + {{- else }} + // FIX(uuid-case): сравниваем без учёта регистра — API возвращает UUID + // в lowercase, пользователь мог написать upper/mixed. Это одно и то же + // значение, менять его нельзя только если оно реально другое. + if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) { + resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.") + return + } + {{- end }} + } + {{- end }} + return + } + {{- range .SchemaParams }} + {{- if and .Required (eq (ParamDefaultExpr .) "") }} + if config.{{ToCamel .Code}}.IsNull() { + resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") + return + } + if config.{{ToCamel .Code}}.IsUnknown() { + return + } + {{- end }} + {{- end }} + + if config.ResourceName.IsNull() || config.ResourceName.IsUnknown() { + return + } + adoptExistingOnCreate := false + if !config.AdoptExistingOnCreate.IsNull() && !config.AdoptExistingOnCreate.IsUnknown() { + adoptExistingOnCreate = config.AdoptExistingOnCreate.ValueBool() + } + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} + if !config.{{ToCamel .Code}}.IsNull() && !config.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, config.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) + } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != config.{{ToCamel .Code}}.ValueString() { + config.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + {{- end }} + params := map[int]string{ + {{- range .CreateParams }} + {{.ID}}: {{ParamFormat . (printf "config.%s" (ToCamel .Code))}}, + {{- end }} + } + desiredDomain := "" + {{- if .HasDomainParam }} + if !config.Domain.IsNull() && !config.Domain.IsUnknown() { + desiredDomain = config.Domain.ValueString() + } + {{- end }} + domainServiceIDs := []int{ {{- range .DomainServiceIDs }}{{.}}, {{- end }} } + + resp.Diagnostics.Append(resources_core.PlanExistingResourceDiagnosticsWithParamsAndDomainAndServices(ctx, r.client, {{.ServiceID}}, config.ResourceName.ValueString(), adoptExistingOnCreate, params, desiredDomain, domainServiceIDs)...) +} + +func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data {{ToCamel .Name}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + {{- range .SchemaParams }} + {{- if and .Required (eq (ParamDefaultExpr .) "") }} + if data.{{ToCamel .Code}}.IsNull() || data.{{ToCamel .Code}}.IsUnknown() { + resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") + return + } + {{- end }} + {{- end }} + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + // ══════════════════════════════════════════════════════════════════════════ + // ПРАВИЛО TERRAFORM (официальная документация): + // «If an attribute value is configured, it is NEVER valid to change that + // value in the plan.» — то есть plan ОБЯЗАН равняться config (= тому что + // написал пользователь). Менять plan запрещено на уровне фреймворка. + // + // ПРОБЛЕМА: API нашего облака возвращает UUID в нижнем регистре. + // Пользователь пишет: vapp_uid = "6214BA32-..." (верхний или смешанный). + // После apply API вернул: "6214ba32-..." → state != plan → Terraform кричит: + // «Provider produced inconsistent result after apply». + // + // РЕШЕНИЕ: корректировать STATE под PLAN, а не наоборот. + // Шаг 1 (здесь): сохраняем оригинальное значение из plan ДО того как + // ResolveRefSvcParamValue переведёт UUID в нижний регистр (нужен для API). + // Шаг 2 (ниже, после RefreshResourceState): восстанавливаем оригинальный + // регистр в state через strings.EqualFold (сравниваем без учёта регистра). + // ══════════════════════════════════════════════════════════════════════════ + original{{ToCamel .Code}} := data.{{ToCamel .Code}} + {{- end }} + {{- end }} + {{- end }} + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + if !data.{{ToCamel .Code}}.IsNull() && !data.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, data.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != data.{{ToCamel .Code}}.ValueString() { + data.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + {{- end }} + + resourceName := data.ResourceName.ValueString() + desiredDomain := "" + {{- if .HasDomainParam }} + if !data.Domain.IsNull() && !data.Domain.IsUnknown() { + desiredDomain = data.Domain.ValueString() + } + {{- end }} + domainServiceIDs := []int{ {{- range .DomainServiceIDs }}{{.}}, {{- end }} } + resp.Diagnostics.Append(resources_core.CreateExistingResourceDiagnosticsWithDomainAndServices(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), desiredDomain, domainServiceIDs)...) + + params := map[int]string{ + {{- range .CreateParams }} + {{.ID}}: {{ParamFormat . (printf "data.%s" (ToCamel .Code))}}, + {{- end }} + } + + operationTimeout := "" + if !data.OperationTimeout.IsNull() && !data.OperationTimeout.IsUnknown() { + operationTimeout = data.OperationTimeout.ValueString() + } + if !data.LogLevel.IsNull() && !data.LogLevel.IsUnknown() { + ctx = core.CtxWithLogLevel(ctx, data.LogLevel.ValueString()) + } + id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + data.ID = types.StringValue(id) + + state, diags := resources_core.RefreshResourceState(ctx, r.client, id, {{.ServiceID}}, data, []resources_core.StateField{ + {{- range .OutputParams }} + {Code: "{{.Code}}"}, + {{- end }} + }, []resources_core.InputField{ + {{- range .SchemaParams }} + {{- if or (le .RefSvcId 0) (ne .RefSvcId 12) }} + {Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"}, + {{- end }} + {{- end }} + }) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, state.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) + } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != state.{{ToCamel .Code}}.ValueString() { + state.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + // Restore user-provided casing в state (Create). + // EqualFold = «совпадают ли значения без учёта регистра?» + // Если да — значит API вернул «туже» строку, только в другом регистре. + // Заменяем state на original (то что было в plan/config пользователя). + // Итог: plan=="6214BA32-..." и state=="6214BA32-..." → нет diff → нет taint. + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + if !original{{ToCamel .Code}}.IsNull() && !original{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { + if strings.EqualFold(state.{{ToCamel .Code}}.ValueString(), original{{ToCamel .Code}}.ValueString()) { + state.{{ToCamel .Code}} = original{{ToCamel .Code}} + } + } + {{- end }} + {{- end }} + {{- end }} + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *{{ToCamel .Name}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state {{ToCamel .Name}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if state.ID.IsNull() || state.ID.IsUnknown() { + return + } + if r.client != nil { + remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if remove { + resp.State.RemoveResource(ctx) + return + } + } + + newState, diags := resources_core.RefreshResourceState(ctx, r.client, state.ID.ValueString(), {{.ServiceID}}, state, []resources_core.StateField{ + {{- range .OutputParams }} + {Code: "{{.Code}}"}, + {{- end }} + }, []resources_core.InputField{ + {{- range .SchemaParams }} + {{- if or (le .RefSvcId 0) (ne .RefSvcId 12) }} + {Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"}, + {{- end }} + {{- end }} + }) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + if !newState.{{ToCamel .Code}}.IsNull() && !newState.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, newState.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) + } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != newState.{{ToCamel .Code}}.ValueString() { + newState.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + // Restore user-provided casing в state (Read). + // При чтении у нас нет plan — но предыдущий state уже хранит значение + // в регистре пользователя (после первого Create оно было восстановлено). + // Берём prior state (переменная state) как эталон регистра. + // Если API вернул то же UUID только строчными буквами — восстанавливаем. + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() && !newState.{{ToCamel .Code}}.IsNull() && !newState.{{ToCamel .Code}}.IsUnknown() { + if strings.EqualFold(newState.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) { + newState.{{ToCamel .Code}} = state.{{ToCamel .Code}} + } + } + {{- end }} + {{- end }} + {{- end }} + + resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) +} + +func (r *{{ToCamel .Name}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan {{ToCamel .Name}}Model + var state {{ToCamel .Name}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + instanceID := state.ID + if instanceID.IsNull() || instanceID.IsUnknown() { + instanceID = plan.ID + } + if instanceID.IsNull() || instanceID.IsUnknown() { + resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра для modify") + return + } + + hasServiceParamChanges := false + {{- range .ModifyParams }} + if !hasServiceParamChanges { + if plan.{{ToCamel .Code}}.IsNull() != state.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() != state.{{ToCamel .Code}}.IsUnknown() { + hasServiceParamChanges = true + } else if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { + {{- if eq (ParamType .) "types.Bool" }} + if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() { + hasServiceParamChanges = true + } + {{- else if eq (ParamType .) "types.Int64" }} + if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() { + hasServiceParamChanges = true + } + {{- else }} + if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) { + hasServiceParamChanges = true + } + {{- end }} + } + } + {{- end }} + + if !hasServiceParamChanges { + plan.ID = instanceID + {{- range .OutputParams }} + plan.{{ToCamel .Code}} = state.{{ToCamel .Code}} + {{- end }} + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + return + } + + params := map[int]string{ + {{- range .ModifyParams }} + {{.ID}}: {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, + {{- end }} + } + + operationTimeout := "" + if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { + operationTimeout = plan.OperationTimeout.ValueString() + } + if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { + ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) + } + if err := resources_core.UpdateResourceWithTimeout(ctx, r.client, instanceID.ValueString(), params, operationTimeout); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = instanceID + state, diags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{ + {{- range .OutputParams }} + {Code: "{{.Code}}"}, + {{- end }} + }, []resources_core.InputField{ + {{- range .SchemaParams }} + {{- if or (le .RefSvcId 0) (ne .RefSvcId 12) }} + {Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"}, + {{- end }} + {{- end }} + }) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, state.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) + } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != state.{{ToCamel .Code}}.ValueString() { + state.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + // Restore user-provided casing в state (Update). + // После API-вызова modify state содержит значения в lower-case от API. + // Plan == config == то что написал пользователь (регистр неизменён). + // EqualFold: если UUID совпадает без учёта регистра — берём из plan. + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} + if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { + if strings.EqualFold(state.{{ToCamel .Code}}.ValueString(), plan.{{ToCamel .Code}}.ValueString()) { + state.{{ToCamel .Code}} = plan.{{ToCamel .Code}} + } + } + {{- end }} + {{- end }} + {{- end }} + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state {{ToCamel .Name}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if state.ID.IsNull() || state.ID.IsUnknown() { + return + } + + {{- if .SupportsSuspendDestroy }} + deleteMode := "state_only" + if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() { + deleteMode = "suspend" + } + {{- else }} + deleteMode := "delete" + {{- end }} + + operationTimeout := "" + if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() { + operationTimeout = state.OperationTimeout.ValueString() + } + if !state.LogLevel.IsNull() && !state.LogLevel.IsUnknown() { + ctx = core.CtxWithLogLevel(ctx, state.LogLevel.ValueString()) + } + if err := resources_core.DeleteResourceWithTimeout(ctx, r.client, state.ID.ValueString(), deleteMode, operationTimeout); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } +} + +func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *{{ToCamel .Name}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Error", "Invalid client type") + return + } + r.client = client +} +` + +const subresourceTemplate = `package resources_gen + +import ( + "context" + "strings" + + "terraform-provider-nubes/internal/core" + "terraform-provider-nubes/internal/resources_core" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + {{- if .NeedsInt64Default }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + {{- end }} + {{- if .NeedsStringDefault }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + {{- end }} + {{- if .NeedsBoolPlanMod }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + {{- end }} + {{- if .NeedsInt64PlanMod }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + {{- end }} + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Code generated by tools/gen_v2. DO NOT EDIT. +// Service: {{.ServiceName}} +// Subresource: {{.SubName}} + +var _ resource.Resource = &{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource{} + +// {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource manages a subresource via operations. +type {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource struct { + client *core.UniversalClient +} + +type {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model struct { + ID types.String ` + "`" + `tfsdk:"id"` + "`" + ` + {{ToCamel .ServiceName}}ID types.String ` + "`" + `tfsdk:"{{ToSnake .ServiceName}}_id"` + "`" + ` + AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` + SkipMissingOnDelete types.Bool ` + "`" + `tfsdk:"skip_missing_on_delete"` + "`" + ` + OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + ` + LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + ` + {{- range .SchemaParams }} + {{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` + {{- end }} +} + +func New{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource() resource.Resource { + return &{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource{} +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_{{.ServiceName}}_{{.SubName}}" +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + attrs := map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, + "{{ToSnake .ServiceName}}_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "skip_missing_on_delete": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "operation_timeout": schema.StringAttribute{Optional: true}, + "log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."}, + {{- range .SchemaParams }} + "{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{ + {{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}} + {{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }} + {{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}} + {{- if .Sensitive }}Sensitive: true,{{end}} + {{- if .ForceNew }}PlanModifiers: []planmodifier.{{PlanModifierType .}}{ {{PlanModifierExpr .}} },{{else if .IsJson}}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{end}} + }, + {{- end }} + } + resp.Schema = schema.Schema{Attributes: attrs} +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + {{- range .SchemaParams }} + {{- if and .Required (eq (ParamDefaultExpr .) "") }} + if plan.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() { + resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") + return + } + {{- end }} + {{- end }} + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} + if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, plan.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != plan.{{ToCamel .Code}}.ValueString() { + plan.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + {{- end }} + + instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) + if instanceUID == "" { + resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра") + return + } + adoptExistingOnCreate := !plan.AdoptExistingOnCreate.IsNull() && !plan.AdoptExistingOnCreate.IsUnknown() && plan.AdoptExistingOnCreate.ValueBool() + + idParams := map[string]string{ + {{- range .IdentityParams }} + "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, + {{- end }} + } + identityCodes := []string{ + {{- range .IdentityParams }} + "{{.Code}}", + {{- end }} + } + listKey, idKey := resources_core.ResolveSubresourceStateKeys("{{.SubName}}", identityCodes) + targetValue := strings.TrimSpace(idParams[idKey]) + if targetValue == "" { + for _, code := range identityCodes { + candidate := strings.TrimSpace(idParams[code]) + if candidate == "" { + continue + } + targetValue = candidate + idKey = code + break + } + } + + if targetValue != "" { + found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if known && found { + if adoptExistingOnCreate { + plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) + resp.Diagnostics.AddWarning("Подресурс уже существует", "Объект уже есть, выполняется усыновление: "+targetValue) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + return + } + resp.Diagnostics.AddError("Подресурс уже существует", "Найден объект с таким именем: "+targetValue) + return + } + } + + params := resources_core.CompactParams(map[string]string{ + {{- range .CreateParams }} + "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, + {{- end }} + }) + + operationTimeout := "" + if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { + operationTimeout = plan.OperationTimeout.ValueString() + } + if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { + ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) + } + createErr := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.CreateOpName}}", params, operationTimeout) + if createErr != nil && resources_core.IsSubresourceTransientDependencyError(createErr) { + createErr = resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.CreateOpName}}", params, operationTimeout) + } + if createErr != nil { + if targetValue != "" { + found, known, checkErr := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) + if checkErr != nil { + resp.Diagnostics.AddError("Ошибка клиента", checkErr.Error()) + return + } + if known && found { + plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) + resp.Diagnostics.AddWarning("Подресурс подтверждён в state_out", "Операция create вернула ошибку, но объект найден в state_out и принят в state: "+targetValue) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + return + } + } + if adoptExistingOnCreate && resources_core.IsSubresourceAlreadyExistsError(createErr) { + if targetValue != "" { + found, known, checkErr := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) + if checkErr != nil { + resp.Diagnostics.AddError("Ошибка клиента", checkErr.Error()) + return + } + if known && found { + plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) + resp.Diagnostics.AddWarning("Подресурс уже существует", "Операция вернула duplicate/exist, объект подтверждён в state_out, выполняется усыновление") + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + return + } + } + resp.Diagnostics.AddError("Нарушена консистентность", "Операция вернула duplicate/exist, но объект не найден в state_out") + return + } + resp.Diagnostics.AddError("Ошибка клиента", createErr.Error()) + return + } + if targetValue != "" { + found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if known && !found { + resp.Diagnostics.AddError("Нарушена консистентность", "Операция create завершилась успешно, но объект не найден в state_out: "+targetValue) + return + } + } + + plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} + if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, plan.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != plan.{{ToCamel .Code}}.ValueString() { + plan.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + {{- end }} + + if "{{.ModifyOpName}}" == "" { + var state {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if plan.ID.IsNull() || plan.ID.IsUnknown() { + plan.ID = state.ID + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + return + } + + instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) + if instanceUID == "" { + resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра") + return + } + + params := resources_core.CompactParams(map[string]string{ + {{- range .ModifyParams }} + "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, + {{- end }} + }) + + idParams := map[string]string{ + {{- range .IdentityParams }} + "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, + {{- end }} + } + + operationTimeout := "" + if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { + operationTimeout = plan.OperationTimeout.ValueString() + } + if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { + ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) + } + if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.ModifyOpName}}", params, operationTimeout); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + {{- if .HasRefSvcParams }} + {{- range .SchemaParams }} + {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} + if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { + resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, state.{{ToCamel .Code}}.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != state.{{ToCamel .Code}}.ValueString() { + state.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) + } + } + {{- end }} + {{- end }} + {{- end }} + + if "{{.DeleteOpName}}" == "" { + return + } + + instanceUID := strings.TrimSpace(state.{{ToCamel .ServiceName}}ID.ValueString()) + if instanceUID == "" { + return + } + skipMissingOnDelete := !state.SkipMissingOnDelete.IsNull() && !state.SkipMissingOnDelete.IsUnknown() && state.SkipMissingOnDelete.ValueBool() + + idParams := map[string]string{ + {{- range .IdentityParams }} + "{{.Code}}": {{ParamFormat . (printf "state.%s" (ToCamel .Code))}}, + {{- end }} + } + identityCodes := []string{ + {{- range .IdentityParams }} + "{{.Code}}", + {{- end }} + } + listKey, idKey := resources_core.ResolveSubresourceStateKeys("{{.SubName}}", identityCodes) + targetValue := strings.TrimSpace(idParams[idKey]) + if targetValue == "" { + for _, code := range identityCodes { + candidate := strings.TrimSpace(idParams[code]) + if candidate == "" { + continue + } + targetValue = candidate + idKey = code + break + } + } + + if targetValue != "" { + found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if known && !found { + if skipMissingOnDelete { + resp.Diagnostics.AddWarning("Подресурс не найден", "Объект отсутствует и будет пропущен: "+targetValue) + return + } + resp.Diagnostics.AddError("Подресурс не найден", "Объект отсутствует: "+targetValue) + return + } + } + + params := resources_core.CompactParams(map[string]string{ + {{- range .DeleteParams }} + "{{.Code}}": {{ParamFormat . (printf "state.%s" (ToCamel .Code))}}, + {{- end }} + }) + + operationTimeout := "" + if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() { + operationTimeout = state.OperationTimeout.ValueString() + } + if !state.LogLevel.IsNull() && !state.LogLevel.IsUnknown() { + ctx = core.CtxWithLogLevel(ctx, state.LogLevel.ValueString()) + } + if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.DeleteOpName}}", params, operationTimeout); err != nil { + if resources_core.IsSubresourceMissingError(err) { + if targetValue != "" { + found, known, checkErr := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) + if checkErr != nil { + resp.Diagnostics.AddError("Ошибка клиента", checkErr.Error()) + return + } + if known && found { + resp.Diagnostics.AddError("Нарушена консистентность", "Операция delete вернула not found, но объект всё ещё присутствует в state_out: "+targetValue) + return + } + } + if skipMissingOnDelete { + resp.Diagnostics.AddWarning("Подресурс не найден", "Операция delete вернула not found, объект отсутствует в state_out, удаление пропущено") + return + } + } + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + if targetValue != "" { + found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if known && found { + resp.Diagnostics.AddError("Нарушена консистентность", "Операция delete завершилась успешно, но объект всё ещё присутствует в state_out: "+targetValue) + return + } + } +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Error", "Invalid client type") + return + } + r.client = client +} +` + +const actionTemplate = `package resources_gen + +import ( + "context" + "strings" + + "terraform-provider-nubes/internal/core" + "terraform-provider-nubes/internal/resources_core" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + {{- if .NeedsInt64Default }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + {{- end }} + {{- if .NeedsStringDefault }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + {{- end }} +{{- if .NeedsJsonPlanMod }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +{{- end }} + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Code generated by tools/gen_v2. DO NOT EDIT. +// Service: {{.ServiceName}} +// Action: {{.ActionName}} + +var _ resource.Resource = &{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource{} + +// {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource triggers a one-time action. +type {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource struct { + client *core.UniversalClient +} + +type {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model struct { + ID types.String ` + "`" + `tfsdk:"id"` + "`" + ` + {{ToCamel .ServiceName}}ID types.String ` + "`" + `tfsdk:"{{ToSnake .ServiceName}}_id"` + "`" + ` + RunID types.String ` + "`" + `tfsdk:"run_id"` + "`" + ` + AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` + OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + ` + LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + ` + {{- range .SchemaParams }} + {{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` + {{- end }} +} + +func New{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource() resource.Resource { + return &{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource{} +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_{{.ServiceName}}_{{.ActionName}}" +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + attrs := map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true}, + "{{ToSnake .ServiceName}}_id": schema.StringAttribute{Required: true}, + "run_id": schema.StringAttribute{Required: true}, + "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "operation_timeout": schema.StringAttribute{Optional: true}, + "log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."}, + {{- range .SchemaParams }} + "{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{ + {{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}} + {{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }} + {{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}} + {{- if .Sensitive }}Sensitive: true,{{end}} + {{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{end}} + }, + {{- end }} + } + resp.Schema = schema.Schema{Attributes: attrs} +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + {{- range .SchemaParams }} + {{- if and .Required (eq (ParamDefaultExpr .) "") }} + if plan.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() { + resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") + return + } + {{- end }} + {{- end }} + + instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) + runID := strings.TrimSpace(plan.RunID.ValueString()) + if instanceUID == "" || runID == "" { + resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра или run_id") + return + } + + params := map[string]string{ + {{- range .SchemaParams }} + "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, + {{- end }} + } + + operationTimeout := "" + if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { + operationTimeout = plan.OperationTimeout.ValueString() + } + if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { + ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) + } + if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.OperationName}}", params, operationTimeout); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(resources_core.BuildActionID(instanceUID, "{{.OperationName}}", runID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) + runID := strings.TrimSpace(plan.RunID.ValueString()) + if instanceUID == "" || runID == "" { + resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра или run_id") + return + } + + params := map[string]string{ + {{- range .SchemaParams }} + "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, + {{- end }} + } + + if err := resources_core.RunOperationByCode(ctx, r.client, instanceUID, "{{.OperationName}}", params); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(resources_core.BuildActionID(instanceUID, "{{.OperationName}}", runID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // No-op: removing the resource does not trigger a remote action. +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Error", "Invalid client type") + return + } + r.client = client +} +` + +// TODO: Support import by resource_name (non-UUID) by resolving instanceUid via API. +// TODO: Handle non-unique names (error or require service_id/resource_realm filter). + +func buildSubresourceForceNewCodes(sr *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 +} + +func markForceNew(params []Param, forceNew map[string]struct{}) []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 +} + +func filterForceNew(params []Param) []Param { + result := []Param{} + for _, p := range params { + if p.ForceNew { + result = append(result, p) + } + } + return result +} + +// analyzeJsonPlanMod возвращает true, если хотя бы один параметр является JSON-строкой. +// Используется для условного добавления импорта planmodifier в сгенерированные файлы. +func analyzeJsonPlanMod(params []Param) bool { + for _, p := range params { + if p.IsJson { + return true + } + } + return false +} + +// hasRestoreCasingParams возвращает true, если есть ref_svc params с RefSvcId > 0 && != 12 +// (restore-casing блок в Create/Read/Update использует strings.EqualFold для них). +func hasRestoreCasingParams(params []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, если среди params есть хотя бы один строковый параметр. +// Используется для условного добавления импорта "strings" (EqualFold в Update). +func analyzeNeedsStrings(params []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 +} + +func analyzePlanModifiers(needsBool *bool, needsInt64 *bool, needsString *bool, params []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 + } + } +} + +func planModifierType(p Param) string { + switch strings.ToLower(strings.TrimSpace(p.Type)) { + case "bool": + return "Bool" + case "int", "int64", "number": + return "Int64" + default: + return "String" + } +} + +func planModifierExpr(p 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()" + } +} diff --git a/universal_rebuild/tools/ops_docs_gen/generate_ops_docs.go b/universal_rebuild/tools/ops_docs_gen/generate_ops_docs.go new file mode 100644 index 0000000..81d00e3 --- /dev/null +++ b/universal_rebuild/tools/ops_docs_gen/generate_ops_docs.go @@ -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: /resources_ops_yaml) +// - NUBES_OPS_DOCS_DIR (default: /../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"` +} diff --git a/universal_rebuild/tools/service_ops_gen/generate_service_ops.go b/universal_rebuild/tools/service_ops_gen/generate_service_ops.go new file mode 100644 index 0000000..dfcb2ac --- /dev/null +++ b/universal_rebuild/tools/service_ops_gen/generate_service_ops.go @@ -0,0 +1,492 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// service_ops_gen: builds per-service ops YAML from /index.cfm?endpoint=/services/{svcId} +// and /index.cfm?endpoint=/serviceOperation/{svcOperationId}. +// +// Env: +// - NUBES_API_TOKEN (preferred) or TOKEN_FILE +// - NUBES_API_ENDPOINT (default: https://deck-api.ngcloud.ru/api/v1/index.cfm) +// - NUBES_SERVICE_ID (optional for single service) +// - NUBES_SERVICE_NAME (optional override for single service) +// - NUBES_SERVICES_FILE (default: devops/config/services_list.txt) +// - NUBES_OUTPUT_DIR (default: /resources_ops_yaml) + +func main() { + cfg, err := loadConfig() + if err != nil { + panic(err) + } + + repoRoot, err := findRepoRoot() + if err != nil { + panic(err) + } + + client := &apiClient{endpoint: cfg.ApiEndpoint, token: cfg.ApiToken} + + services := cfg.Services + if cfg.SingleServiceID > 0 { + services = []serviceRef{{ID: cfg.SingleServiceID, Name: cfg.SingleServiceName}} + } + + outputDir := cfg.OutputDir + if outputDir == "" { + outputDir = filepath.Join(repoRoot, "resources_ops_yaml") + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + panic(err) + } + + for _, svc := range services { + if svc.ID <= 0 { + continue + } + info, err := client.getService(svc.ID) + if err != nil { + panic(err) + } + name := strings.TrimSpace(svc.Name) + if name == "" { + name = strings.TrimSpace(info.ShortName) + } + if name == "" { + name = strings.TrimSpace(info.DisplayName) + } + if name == "" { + name = fmt.Sprintf("service_%d", svc.ID) + } + + opSpecs, err := client.collectOperations(info.Operations) + if err != nil { + panic(err) + } + + spec := ServiceOpsSpec{ + Name: name, + ServiceID: svc.ID, + ServiceDisplayName: info.DisplayName, + ServiceShortName: info.ShortName, + ServiceMan: strings.TrimSpace(info.Man), + Operations: opSpecs, + } + + 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) { + apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm") + 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 +} + +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 +} + +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 "", errors.New("failed to locate universal_rebuild repo root") +} + +// ===== api client ===== + +type apiClient struct { + endpoint string + token string +} + +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) { + endpoint := fmt.Sprintf("/services/%d", serviceID) + var res serviceResponse + if err := c.getViaProxy(endpoint, &res); err != nil { + return serviceInfo{}, err + } + return res.Service, nil +} + +func (c *apiClient) getServiceOperation(svcOperationId int) (serviceOperationInfo, error) { + endpoint := fmt.Sprintf("/serviceOperation/%d", svcOperationId) + var res serviceOperationResponse + if err := c.getViaProxy(endpoint, &res); err != nil { + return serviceOperationInfo{}, err + } + return res.ServiceOperation, nil +} + +func (c *apiClient) getViaProxy(endpoint string, out interface{}) error { + req, err := http.NewRequest("GET", c.endpoint, nil) + if err != nil { + return err + } + q := req.URL.Query() + q.Set("endpoint", endpoint) + req.URL.RawQuery = q.Encode() + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + dec := json.NewDecoder(resp.Body) + return dec.Decode(out) +} + +func (c *apiClient) collectOperations(ops []operationInfo) ([]OperationSpec, error) { + result := make([]OperationSpec, 0, len(ops)) + for _, op := range ops { + opName := strings.ToLower(strings.TrimSpace(op.Operation)) + if opName == "" { + continue + } + opInfo, err := c.getServiceOperation(op.SvcOperationId) + if err != nil { + return nil, err + } + 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 }) + result = append(result, OperationSpec{ + Name: opName, + ID: opInfo.SvcOperationId, + 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, nil +} + +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 + } +} + +// ===== 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"` +} diff --git a/universal_rebuild/tools/service_params_gen/generate_service_params.go b/universal_rebuild/tools/service_params_gen/generate_service_params.go new file mode 100644 index 0000000..05365c7 --- /dev/null +++ b/universal_rebuild/tools/service_params_gen/generate_service_params.go @@ -0,0 +1,523 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// service_params_gen: builds resource YAML from /index.cfm?endpoint=/services/{svcId} +// and /index.cfm?endpoint=/serviceOperation/{svcOperationId}. +// +// Env: +// - NUBES_API_TOKEN (or read from test_universal/terraform.tfvars) +// - NUBES_API_ENDPOINT (default: https://deck-api.ngcloud.ru/api/v1/index.cfm) +// - NUBES_SERVICE_ID (required) +// - NUBES_SERVICE_NAME (optional override for YAML name) +// - NUBES_OUTPUT (optional output path) + +func main() { + cfg, err := loadConfig() + if err != nil { + panic(err) + } + + repoRoot, err := findRepoRoot() + if err != nil { + panic(err) + } + extraDir := filepath.Join(repoRoot, "extra-params") + if err := validateExtraParams(extraDir); err != nil { + fmt.Fprintf(os.Stderr, "extra-params validation failed: %v\n", err) + os.Exit(1) + } + + client := &apiClient{ + endpoint: cfg.ApiEndpoint, + token: cfg.ApiToken, + } + + service, err := client.getService(cfg.ServiceID) + if err != nil { + panic(err) + } + + name := cfg.ServiceName + if name == "" { + name = service.Name + } + if name == "" { + name = fmt.Sprintf("service_%d", cfg.ServiceID) + } + + ops, createMan, hasSuspend, err := client.collectOperations(service.Operations) + if err != nil { + panic(err) + } + suspendOnDestroyDefault := hasSuspend + + spec := ResourceSpec{ + Name: name, + ServiceID: cfg.ServiceID, + Create: Operation{Params: ops["create"]}, + Modify: Operation{Params: ops["modify"]}, + Outputs: OutputSection{Params: defaultOutputParams()}, + Lifecycle: Lifecycle{SuspendOnDestroyDefault: suspendOnDestroyDefault, AdoptExistingOnCreateDefault: false}, + } + + out, err := yaml.Marshal(spec) + if err != nil { + panic(err) + } + + if cfg.OutputPath != "" { + if err := os.WriteFile(cfg.OutputPath, out, 0o644); err != nil { + panic(err) + } + fmt.Printf("written %s\n", cfg.OutputPath) + if err := writeInstruction(cfg.InstructionPath, name, service.Man, createMan); err != nil { + panic(err) + } + return + } + + fmt.Print(string(out)) +} + +func writeInstruction(path string, name string, serviceMan string, createMan string) error { + man := strings.TrimSpace(serviceMan) + if man == "" { + man = strings.TrimSpace(createMan) + } + if man == "" { + return nil + } + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + content := fmt.Sprintf("# Инструкция для %s\n\n%s\n", name, man) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return err + } + fmt.Printf("written %s\n", path) + return nil +} + +// ===== config ===== + +type config struct { + ApiEndpoint string + ApiToken string + ServiceID int + ServiceName string + OutputPath string + InstructionPath string +} + +func loadConfig() (config, error) { + apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm") + apiToken := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")) + if apiToken == "" { + apiToken = strings.TrimSpace(readTokenFromTfvars("/home/naeel/terra/universal_rebuild/test_universal/terraform.tfvars")) + } + if apiToken == "" { + return config{}, errors.New("NUBES_API_TOKEN is required") + } + + serviceIDStr := strings.TrimSpace(os.Getenv("NUBES_SERVICE_ID")) + if serviceIDStr == "" { + return config{}, errors.New("NUBES_SERVICE_ID is required") + } + serviceID, err := strconv.Atoi(serviceIDStr) + if err != nil { + return config{}, fmt.Errorf("invalid NUBES_SERVICE_ID: %s", serviceIDStr) + } + + name := strings.TrimSpace(os.Getenv("NUBES_SERVICE_NAME")) + output := strings.TrimSpace(os.Getenv("NUBES_OUTPUT")) + if output == "" { + output = filepath.Join("/home/naeel/terra/universal_rebuild/resources_yaml", fmt.Sprintf("%s.yaml", nameOrDefault(name, serviceID))) + } + instructionOutput := strings.TrimSpace(os.Getenv("NUBES_INSTRUCTION_OUTPUT")) + if instructionOutput == "" { + instructionOutput = filepath.Join("/home/naeel/terra/docs/30_registry/resources", fmt.Sprintf("%s_instruction.md", nameOrDefault(name, serviceID))) + } + + return config{ + ApiEndpoint: apiEndpoint, + ApiToken: apiToken, + ServiceID: serviceID, + ServiceName: name, + OutputPath: output, + InstructionPath: instructionOutput, + }, nil +} + +func nameOrDefault(name string, serviceID int) string { + if strings.TrimSpace(name) != "" { + return name + } + return fmt.Sprintf("service_%d", serviceID) +} + +func getenvDefault(key, def string) string { + val := strings.TrimSpace(os.Getenv(key)) + if val == "" { + return def + } + return val +} + +func readTokenFromTfvars(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + re := regexp.MustCompile(`(?m)^\s*api_token\s*=\s*"([^"]+)"`) + m := re.FindStringSubmatch(string(b)) + if len(m) < 2 { + return "" + } + return m[1] +} + +// ===== api client ===== + +type apiClient struct { + endpoint string + token string +} + +type serviceResponse struct { + Service serviceInfo `json:"svc"` +} + +type serviceInfo struct { + ID int `json:"svcId"` + Name 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"` + IsRequired bool `json:"isRequired"` + DefaultValue interface{} `json:"defaultValue"` + // NOTE(name-to-uuid): refSvcId marks params referencing another service instance. + RefSvcId *int `json:"refSvcId"` +} + +func (c *apiClient) getService(serviceID int) (serviceInfo, error) { + endpoint := fmt.Sprintf("/services/%d", serviceID) + var res serviceResponse + if err := c.getViaProxy(endpoint, &res); err != nil { + return serviceInfo{}, err + } + return res.Service, nil +} + +func (c *apiClient) getServiceOperation(svcOperationId int) (serviceOperationInfo, error) { + endpoint := fmt.Sprintf("/serviceOperation/%d", svcOperationId) + var res serviceOperationResponse + if err := c.getViaProxy(endpoint, &res); err != nil { + return serviceOperationInfo{}, err + } + return res.ServiceOperation, nil +} + +func (c *apiClient) getViaProxy(endpoint string, out interface{}) error { + req, err := http.NewRequest("GET", c.endpoint, nil) + if err != nil { + return err + } + q := req.URL.Query() + q.Set("endpoint", endpoint) + req.URL.RawQuery = q.Encode() + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + dec := json.NewDecoder(resp.Body) + return dec.Decode(out) +} + +func (c *apiClient) collectOperations(ops []operationInfo) (map[string][]Param, string, bool, error) { + result := map[string][]Param{ + "create": {}, + "modify": {}, + } + createMan := "" + hasSuspend := false + for _, op := range ops { + name := strings.ToLower(strings.TrimSpace(op.Operation)) + if name == "suspend" { + hasSuspend = true + } + if name != "create" && name != "modify" { + continue + } + opInfo, err := c.getServiceOperation(op.SvcOperationId) + if err != nil { + return nil, "", false, err + } + if name == "create" && createMan == "" { + createMan = strings.TrimSpace(opInfo.Man) + } + params := make([]Param, 0, len(opInfo.CfsParams)) + for _, p := range opInfo.CfsParams { + // Name -> UUID mapping is driven by refSvcId metadata from API. + // We persist it in YAML so the Go generator can mark name-based inputs. + params = append(params, Param{ + ID: p.ID, + Code: p.Code, + Type: mapType(p.DataType), + Required: p.IsRequired, + Default: formatDefault(p.DefaultValue), + RefSvcId: refSvcIdValue(p.RefSvcId), + }) + } + sort.Slice(params, func(i, j int) bool { return params[i].ID < params[j].ID }) + result[name] = params + } + return result, createMan, hasSuspend, nil +} + +func mapType(dt string) string { + d := strings.ToLower(strings.TrimSpace(dt)) + if strings.Contains(d, "bool") { + return "bool" + } + if strings.Contains(d, "int") { + return "int64" + } + return "string" +} + +func formatDefault(v interface{}) string { + if v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case bool: + if t { + return "true" + } + return "false" + case float64: + if t == float64(int64(t)) { + return fmt.Sprintf("%d", int64(t)) + } + return fmt.Sprintf("%v", t) + default: + b, _ := json.Marshal(t) + return string(b) + } +} + +// ===== spec ===== + +type ResourceSpec struct { + Name string `yaml:"name"` + ServiceID int `yaml:"service_id"` + Create Operation `yaml:"create"` + Modify Operation `yaml:"modify"` + Outputs OutputSection `yaml:"outputs"` + Lifecycle Lifecycle `yaml:"lifecycle"` +} + +type Operation struct { + Params []Param `yaml:"params"` +} + +type OutputSection struct { + Params []OutputParam `yaml:"params"` +} + +type Lifecycle struct { + SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"` + AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"` +} + +type Param struct { + ID int `yaml:"id"` + Code string `yaml:"code"` + Type string `yaml:"type"` + Required bool `yaml:"required"` + Default string `yaml:"default,omitempty"` + RefSvcId int `yaml:"ref_svc_id,omitempty"` +} + +type OutputParam struct { + Code string `yaml:"code"` + Type string `yaml:"type"` + Sensitive bool `yaml:"sensitive,omitempty"` +} + +func refSvcIdValue(value *int) int { + if value == nil { + return 0 + } + return *value +} + +func defaultOutputParams() []OutputParam { + 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"}, + } +} + +// ===== extra params validation ===== + +type ExtraParamSpec struct { + Type string `yaml:"type"` + Required bool `yaml:"required"` +} + +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 "", errors.New("failed to locate universal_rebuild repo root") +} + +func validateExtraParams(extraDir string) error { + entries, err := os.ReadDir(extraDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + if _, err := parseServiceID(entry.Name()); err != nil { + return err + } + path := filepath.Join(extraDir, entry.Name()) + if err := validateExtraParamsFile(path); err != nil { + return err + } + } + return nil +} + +func validateExtraParamsFile(path string) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + var raw []map[string]ExtraParamSpec + if err := yaml.Unmarshal(b, &raw); err != nil { + return fmt.Errorf("invalid extra params yaml %s: %w", path, err) + } + for _, item := range raw { + if len(item) != 1 { + return fmt.Errorf("invalid extra params entry in %s: expected single key", path) + } + for code, spec := range item { + if strings.TrimSpace(code) == "" { + return fmt.Errorf("invalid extra params entry in %s: empty code", path) + } + _ = normalizeExtraType(spec.Type) + } + } + return nil +} + +func normalizeExtraType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "string" + } + switch value { + case "bool", "boolean": + return "bool" + case "int", "int64", "number": + return "int64" + default: + return "string" + } +} + +func parseServiceID(name string) (int, error) { + trimmed := strings.TrimSpace(name) + var digits []rune + for _, r := range trimmed { + if r < '0' || r > '9' { + break + } + digits = append(digits, r) + } + if len(digits) == 0 { + return 0, fmt.Errorf("extra params file %q must start with service id", name) + } + value, err := strconv.Atoi(string(digits)) + if err != nil { + return 0, fmt.Errorf("invalid service id in extra params file %q", name) + } + return value, nil +} diff --git a/universal_rebuild/tools/service_spec_gen/generate_service_spec.go b/universal_rebuild/tools/service_spec_gen/generate_service_spec.go new file mode 100644 index 0000000..7c93e8f --- /dev/null +++ b/universal_rebuild/tools/service_spec_gen/generate_service_spec.go @@ -0,0 +1,715 @@ +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) +// - NUBES_SERVICE_ID (optional for single service) +// - NUBES_SERVICE_NAME (optional override for single service) +// - NUBES_SERVICES_FILE (default: devops/config/services_list.txt) +// - NUBES_OUTPUT_DIR (default: /resources_yaml) + +func main() { + // Load env and services list configuration first so we can derive output paths. + cfg, err := loadConfig() + if err != nil { + panic(err) + } + + // API client uses proxy endpoint with bearer token. + client := newAPIClient(cfg.ApiEndpoint, cfg.ApiToken) + + // If a single service is requested, override the list from services_list.txt. + services := cfg.Services + if cfg.SingleServiceID > 0 { + services = []serviceRef{{ID: cfg.SingleServiceID, Name: cfg.SingleServiceName}} + } + + // Unified YAML output directory. We never emit resources_ops_yaml anymore. + outputDir := cfg.OutputDir + if outputDir == "" { + // Resolve the repo root only if we need the default output dir. + repoRoot, err := findRepoRoot() + if err != nil { + panic(err) + } + outputDir = filepath.Join(repoRoot, "resources_yaml") + } + if err := validateOutputDir(outputDir); err != nil { + panic(err) + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + panic(err) + } + + for _, svc := range services { + if svc.ID <= 0 { + continue + } + // Fetch service metadata and the operation list. + info, err := client.getService(svc.ID) + if err != nil { + panic(err) + } + // Prefer the explicit name from services_list.txt; otherwise derive from API. + name := strings.TrimSpace(svc.Name) + if name == "" { + name = strings.TrimSpace(info.ShortName) + } + if name == "" { + name = strings.TrimSpace(info.DisplayName) + } + name = normalizeServiceName(name) + if name == "" { + name = fmt.Sprintf("service_%d", svc.ID) + } + + // Collect operations and derive lifecycle defaults based on suspend/resume. + ops, hasSuspend, _, _, err := client.collectOperations(info.Operations) + if err != nil { + panic(err) + } + + suspendOnDestroyDefault := hasSuspend + adoptExistingOnCreateDefault := false + + // Build the unified service YAML spec. This is the only YAML for the service. + spec := ServiceSpec{ + Name: name, + ServiceID: svc.ID, + ServiceDisplayName: strings.TrimSpace(info.DisplayName), + ServiceShortName: strings.TrimSpace(info.ShortName), + ServiceMan: strings.TrimSpace(info.Man), + Lifecycle: Lifecycle{ + SuspendOnDestroyDefault: suspendOnDestroyDefault, + AdoptExistingOnCreateDefault: adoptExistingOnCreateDefault, + }, + Outputs: OutputSection{Params: defaultOutputParams()}, + Operations: ops, + } + + // Write _.yaml to resources_yaml. + outPath := filepath.Join(outputDir, fmt.Sprintf("%d_%s.yaml", svc.ID, name)) + buf, err := yaml.Marshal(spec) + if err != nil { + panic(err) + } + if err := os.WriteFile(outPath, buf, 0o644); err != nil { + panic(err) + } + fmt.Printf("written %s\n", outPath) + } +} + +// ===== config ===== + +type config struct { + ApiEndpoint string + ApiToken string + Services []serviceRef + SingleServiceID int + SingleServiceName string + OutputDir string +} + +type serviceRef struct { + ID int + Name string +} + +func loadConfig() (config, error) { + // API endpoint defaults to production; token is required. + apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm") + apiEndpoint = normalizeAPIEndpoint(apiEndpoint) + apiToken, err := loadToken() + if err != nil { + return config{}, err + } + + // Optional single-service mode for debugging and local inspection. + singleID := 0 + if raw := strings.TrimSpace(os.Getenv("NUBES_SERVICE_ID")); raw != "" { + val, err := strconv.Atoi(raw) + if err != nil { + return config{}, fmt.Errorf("invalid NUBES_SERVICE_ID: %s", raw) + } + singleID = val + } + + singleName := strings.TrimSpace(os.Getenv("NUBES_SERVICE_NAME")) + outputDir := strings.TrimSpace(os.Getenv("NUBES_OUTPUT_DIR")) + + services := []serviceRef{} + if singleID == 0 { + // services_list.txt is the source of truth for which services to generate. + listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE")) + if listPath == "" { + repoRoot, err := findRepoRoot() + if err != nil { + return config{}, err + } + listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt") + } + list, err := readServicesList(listPath) + if err != nil { + return config{}, err + } + services = list + } + + return config{ + ApiEndpoint: apiEndpoint, + ApiToken: apiToken, + Services: services, + SingleServiceID: singleID, + SingleServiceName: singleName, + OutputDir: outputDir, + }, nil +} + +func getenvDefault(key string, def string) string { + val := strings.TrimSpace(os.Getenv(key)) + if val == "" { + return def + } + return val +} + +func loadToken() (string, error) { + // Prefer explicit env token, then TOKEN_FILE, then the latest *.token. + if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" { + return tok, nil + } + if tf := strings.TrimSpace(os.Getenv("TOKEN_FILE")); tf != "" { + b, err := os.ReadFile(tf) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil + } + repoRoot, err := findRepoRoot() + if err != nil { + return "", err + } + latest, err := findLatestToken(repoRoot) + if err != nil { + return "", err + } + if latest == "" { + return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required") + } + b, err := os.ReadFile(latest) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +func findLatestToken(dir string) (string, error) { + // Pick the newest *.token file by mtime. + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + var latest string + var latestTime int64 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + mt := info.ModTime().Unix() + if mt > latestTime { + latestTime = mt + latest = filepath.Join(dir, e.Name()) + } + } + return latest, nil +} + +func readServicesList(path string) ([]serviceRef, error) { + // Each non-empty line is: [# comment] + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + lines := strings.Split(string(b), "\n") + services := []serviceRef{} + for _, raw := range lines { + line := strings.TrimSpace(strings.ReplaceAll(raw, "\r", "")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, "#") { + line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0]) + } + parts := strings.Fields(line) + if len(parts) == 0 { + continue + } + id, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid service id in %s: %s", path, parts[0]) + } + name := "" + if len(parts) > 1 { + name = parts[1] + } + services = append(services, serviceRef{ID: id, Name: name}) + } + return services, nil +} + +func findRepoRoot() (string, error) { + // We locate the repo by finding the universal_rebuild directory. + wd, err := os.Getwd() + if err != nil { + return "", err + } + current := wd + for i := 0; i < 8; i++ { + candidate := filepath.Join(current, "universal_rebuild") + info, err := os.Stat(candidate) + if err == nil && info.IsDir() { + return candidate, nil + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return "", errors.New("failed to locate universal_rebuild repo root") +} + +// ===== api client ===== + +type apiClient struct { + endpoint string + token string + httpClient *http.Client +} + +type serviceResponse struct { + Service serviceInfo `json:"svc"` +} + +type serviceInfo struct { + ID int `json:"svcId"` + DisplayName string `json:"svc"` + ShortName string `json:"svcShort"` + Man string `json:"man"` + Operations []operationInfo `json:"operations"` +} + +type operationInfo struct { + SvcOperationId int `json:"svcOperationId"` + Operation string `json:"operation"` +} + +type serviceOperationResponse struct { + ServiceOperation serviceOperationInfo `json:"svcOperation"` +} + +type serviceOperationInfo struct { + SvcOperationId int `json:"svcOperationId"` + Operation string `json:"operation"` + Man string `json:"man"` + CfsParams []cfsParam `json:"cfsParams"` +} + +type cfsParam struct { + ID int `json:"svcOperationCfsParamId"` + Code string `json:"svcOperationCfsParam"` + DataType string `json:"dataType"` + ValueList []interface{} `json:"valueList"` + RefSvcId *int `json:"refSvcId"` + IsRequired bool `json:"isRequired"` + DefaultValue interface{} `json:"defaultValue"` + Func string `json:"func"` + Regex string `json:"regex"` + UniqueScope string `json:"uniqueScope"` + MaxLength *int `json:"maxlength"` + MinLength *int `json:"minlength"` + MaxValue interface{} `json:"maxvalue"` + MinValue interface{} `json:"minvalue"` + Descr string `json:"descr"` + Man string `json:"man"` + Sort *int `json:"sort"` + DependsOnCfsParams interface{} `json:"dependsOnCfsParams"` + IsModifiable *bool `json:"isModifiable"` + IsSensitive bool `json:"isSensitive"` +} + +func (c *apiClient) getService(serviceID int) (serviceInfo, error) { + // /services/{id} returns display names, MAN, and a list of operations. + endpoint := fmt.Sprintf("/services/%d", serviceID) + var res serviceResponse + if err := c.getViaProxy(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.getViaProxy(endpoint, &res); err != nil { + return serviceOperationInfo{}, err + } + return res.ServiceOperation, nil +} + +func (c *apiClient) getViaProxy(endpoint string, out interface{}) error { + // The API uses a proxy query parameter called "endpoint". + req, err := http.NewRequest("GET", c.endpoint, nil) + if err != nil { + return err + } + q := req.URL.Query() + q.Set("endpoint", endpoint) + req.URL.RawQuery = q.Encode() + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + dec := json.NewDecoder(resp.Body) + return dec.Decode(out) +} + +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 -> instance + // - create_*/modify_*/delete_* -> subresource + // - everything else -> action + n := strings.ToLower(strings.TrimSpace(name)) + if n == "create" || n == "modify" || n == "delete" || n == "suspend" || n == "resume" { + return "instance", n, "" + } + if strings.HasPrefix(n, "create_") { + sub := strings.TrimPrefix(n, "create_") + if sub != "" { + return "subresource", "create", sub + } + } + if strings.HasPrefix(n, "modify_") { + sub := strings.TrimPrefix(n, "modify_") + if sub != "" { + return "subresource", "modify", sub + } + } + if strings.HasPrefix(n, "delete_") { + sub := strings.TrimPrefix(n, "delete_") + if sub != "" { + return "subresource", "delete", 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 base API URL with or without index.cfm; normalize to index.cfm. + endpoint := strings.TrimSpace(raw) + if endpoint == "" { + return endpoint + } + if strings.HasSuffix(endpoint, "/index.cfm") { + return endpoint + } + return strings.TrimRight(endpoint, "/") + "/index.cfm" +}