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("| ID | Code | Type | Description | Constraints |
\n\n")
+ } else {
+ b.WriteString("| ID | Code | Type | Default | Description | Constraints |
\n\n")
+ }
+ for _, p := range params {
+ b.WriteString("")
+ b.WriteString(fmt.Sprintf("| %s | ", escapeText(formatID(p.ID))))
+ b.WriteString(fmt.Sprintf("%s | ", formatCode(p.Code)))
+ b.WriteString(fmt.Sprintf("%s | ", formatTypeCell(p)))
+ if !noDefault {
+ b.WriteString(fmt.Sprintf("%s | ", defaultCell(p.Default)))
+ }
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(pickText(p))))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(collectConstraints(p))))
+ b.WriteString("
\n")
+ }
+ b.WriteString("
\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("| ID | Code | Type | Description | Constraints |
\n\n")
+ for _, p := range params {
+ b.WriteString("")
+ b.WriteString(fmt.Sprintf("| %s | ", escapeText(formatID(p.ID))))
+ b.WriteString(fmt.Sprintf("%s | ", formatCode(p.Code)))
+ b.WriteString(fmt.Sprintf("%s | ", formatTypeCell(p)))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(pickText(p))))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(collectConstraints(p))))
+ b.WriteString("
\n")
+ }
+ b.WriteString("
\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("| ID | Code | Type | Description | Constraints |
\n\n")
+ } else {
+ b.WriteString("| ID | Code | Type | Default | Description | Constraints |
\n\n")
+ }
+ for _, p := range params {
+ b.WriteString("")
+ b.WriteString(fmt.Sprintf("| %s | ", escapeText(formatID(p.ID))))
+ b.WriteString(fmt.Sprintf("%s | ", formatParamCode(p.Code)))
+ b.WriteString(fmt.Sprintf("%s | ", formatTypeCell(p)))
+ if !noDefault {
+ b.WriteString(fmt.Sprintf("%s | ", defaultCell(p.Default)))
+ }
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(pickTextTable(p))))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(collectConstraints(p))))
+ b.WriteString("
\n")
+ }
+ b.WriteString("
\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("| ID | Code | Type | Description | Constraints |
\n\n")
+ for _, p := range params {
+ b.WriteString("")
+ b.WriteString(fmt.Sprintf("| %s | ", escapeText(formatID(p.ID))))
+ b.WriteString(fmt.Sprintf("%s | ", formatParamCode(p.Code)))
+ b.WriteString(fmt.Sprintf("%s | ", formatTypeCell(p)))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(pickTextTable(p))))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(collectConstraints(p))))
+ b.WriteString("
\n")
+ }
+ b.WriteString("
\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"
+}