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", "", "Comma-separated resource names to skip (e.g. clickhouse)") versionFlag := flag.String("version", "", "Provider version for example block") apiEndpointFlag := flag.String("api-endpoint", "https://deck-api.ngcloud.ru/api/v1/index.cfm", "API endpoint 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")) } apiEndpoint := *apiEndpointFlag 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 { fmt.Fprintf(os.Stderr, "ERROR: cannot create docs dir %s: %v\n", docsDir, err) os.Exit(1) } var processedSpecs []ServiceSpec for _, spec := range specs { if excludeSet[spec.Name] { continue } writeResourceDocs(docsDir, spec, version, apiEndpoint) processedSpecs = append(processedSpecs, spec) } generateIndexMD(docsDir, processedSpecs) } func detectRoot() string { cwd, err := os.Getwd() if err != nil { fmt.Fprintf(os.Stderr, "ERROR: cannot get working directory: %v\n", err) os.Exit(1) } 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 { fmt.Fprintf(os.Stderr, "ERROR: failed to load specs from %s: %v\n", dir, walkErr) os.Exit(1) } 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, apiEndpoint 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, apiEndpoint)) 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, apiEndpoint)) } } func writeFile(path, content string) { if err := os.WriteFile(path, []byte(content), 0o644); err != nil { fmt.Fprintf(os.Stderr, "ERROR: cannot write %s: %v\n", path, err) os.Exit(1) } } 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, apiEndpoint 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, apiEndpoint)) 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, apiEndpoint 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(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint)) b.WriteString("}\n") b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name)) for _, p := range requiredParams { b.WriteString(formatParamLine(p, " ", true)) } if len(defaultParams) > 0 { b.WriteString("\n # \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u0438\u043c\u0435\u044e\u0449\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u0435\u0441\u043b\u0438 \u043d\u0435 \u043c\u0435\u043d\u044f\u0435\u0442\u0435 - \u044d\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043d\u0435 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u0440\u043e\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0432 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0435\n") for _, p := range defaultParams { b.WriteString(formatParamLine(p, " ", false)) } } b.WriteString("}\n") return b.String() } func buildCreateParamsPage(spec ServiceSpec, nav string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav)) b.WriteString("## Create params\n\n") createParams := findParams(spec.Operations, "create") requiredParams, defaultParams := splitParams(createParams) b.WriteString("**\u041e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b (\u0432\u0432\u043e\u0434\u0438\u043c\u044b\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u043c)**\n\n") b.WriteString(renderParamTable(requiredParams, true, true)) b.WriteString("\n**\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u0438\u043c\u0435\u044e\u0449\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u0435\u0441\u043b\u0438 \u043d\u0435 \u043c\u0435\u043d\u044f\u0435\u0442\u0435 - \u044d\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043d\u0435 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u0440\u043e\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0432 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0435**\n") b.WriteString(renderParamTable(defaultParams, false, false)) lifecycle := spec.Lifecycle if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault { b.WriteString("\n
\n") b.WriteString("\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f (\u043a\u0440\u0430\u0442\u043a\u043e)
\n") b.WriteString(fmt.Sprintf("\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: `suspend_on_destroy = %s`, `adopt_existing_on_create = %s`.
\n", formatBoolTitle(lifecycle.SuspendOnDestroyDefault), formatBoolTitle(lifecycle.AdoptExistingOnCreateDefault))) b.WriteString("\u041f\u0440\u0438 `terraform destroy` \u0438\u043b\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0438 \u0440\u0435\u0441\u0443\u0440\u0441\u0430 \u0438\u0437 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0430:
\n") b.WriteString("- `suspend_on_destroy=true` \u2014 \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u043f\u0435\u0440\u0435\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0432 `Suspend` (\u043d\u0435 \u0443\u0434\u0430\u043b\u044f\u0435\u0442\u0441\u044f).
\n") b.WriteString("- `suspend_on_destroy=false` \u2014 Terraform \u0443\u0434\u0430\u043b\u044f\u0435\u0442 \u0440\u0435\u0441\u0443\u0440\u0441 \u0442\u043e\u043b\u044c\u043a\u043e \u0438\u0437 state.
\n") b.WriteString("\u041f\u0440\u0438 `apply` \u0444\u043b\u0430\u0433 `adopt_existing_on_create` \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043a\u0430\u043a \u0430\u0432\u0442\u043e-`import`:
\n") b.WriteString("- `false` \u2014 \u0435\u0441\u043b\u0438 \u0440\u0435\u0441\u0443\u0440\u0441 \u0443\u0436\u0435 \u0435\u0441\u0442\u044c, \u0431\u0443\u0434\u0435\u0442 \u043e\u0448\u0438\u0431\u043a\u0430.
\n") b.WriteString("- `true` \u2014 Terraform \u043c\u043e\u0436\u0435\u0442 \u0432\u0437\u044f\u0442\u044c \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0439 \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u043f\u043e\u0434 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 (`running` \u2192 adopt, `suspended` \u2192 resume+adopt \u043f\u0440\u0438 \u0441\u043e\u0432\u043f\u0430\u0434\u0435\u043d\u0438\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432).
\n") b.WriteString("\u0412\u0430\u0436\u043d\u043e: \u043e\u0434\u0438\u043d \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u043e\u0434\u043d\u043e\u043c state. \u0418\u043d\u0430\u0447\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f.\n") b.WriteString("
\n") } return b.String() } func buildModifyParamsPage(spec ServiceSpec, nav string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav)) b.WriteString("## Modify params\n\n") modifyParams := findParams(spec.Operations, "modify") b.WriteString(renderModifyTable(modifyParams)) return b.String() } func buildOutputsPage(spec ServiceSpec, nav string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav)) b.WriteString("## Output params\n\n") if len(spec.Outputs.Params) == 0 { b.WriteString("None.\n") return b.String() } for _, p := range spec.Outputs.Params { desc := outputDescription(p.Code) if desc != "" { b.WriteString(fmt.Sprintf("- `%s` (%s) \u2014 %s\n", p.Code, p.Type, desc)) } else { b.WriteString(fmt.Sprintf("- `%s` (%s)\n", p.Code, p.Type)) } } snapshot, ok := cloudOutputByServiceID[spec.ServiceID] if !ok { return b.String() } hasOut := len(snapshot.OutPaths) > 0 hasVault := len(snapshot.VaultFields) > 0 if !hasOut && !hasVault { return b.String() } b.WriteString("\n## Реальные поля из облака (running/suspended snapshot)\n\n") b.WriteString("Поля ниже получены из реальных инстансов этого сервиса в облаке.\n") b.WriteString("Используйте их как готовые ключи для `state_out_flat` и `vault_secrets`.\n\n") if hasOut { b.WriteString("### `state_out_flat` ключи\n\n") for _, key := range snapshot.OutPaths { if strings.TrimSpace(key) == "" { continue } b.WriteString(fmt.Sprintf("- `state_out_flat[\"%s\"]`\n", key)) } b.WriteString("\n") } if hasVault { b.WriteString("### `vault_secrets` ключи\n\n") for _, key := range snapshot.VaultFields { if strings.TrimSpace(key) == "" { continue } b.WriteString(fmt.Sprintf("- `vault_secrets[\"%s\"]`\n", key)) } b.WriteString("\n") } if spec.ServiceID == 90 && containsString(snapshot.OutPaths, "internalConnect.master") && containsString(snapshot.VaultFields, "adminUser") && containsString(snapshot.VaultFields, "adminPass") { b.WriteString("### Пример для связки с Lucee/NodeJS\n\n") b.WriteString("```hcl\n") b.WriteString("testds_connectionString = \"jdbc:postgresql://${nubes_postgres.db2.state_out_flat[\"internalConnect.master\"]}:5432/postgres?sslmode=require\"\n") b.WriteString("testds_username = nubes_postgres.db2.vault_secrets[\"adminUser\"]\n") b.WriteString("testds_password = nubes_postgres.db2.vault_secrets[\"adminPass\"]\n") b.WriteString("```\n") } return b.String() } func containsString(values []string, target string) bool { for _, value := range values { if value == target { return true } } return false } func buildOpsPage(spec ServiceSpec, nav, docsDir string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav)) b.WriteString("## Operations\n\n") for _, op := range spec.Operations { name := op.Name if name == "" { name = op.Action } descr := strings.TrimSpace(stripHTML(op.Man)) if descr != "" { descr = ": " + descr } // Subresource-операции проверяем ДО switch, чтобы create_user/create_database // не попадали в case "create" основного ресурса if op.Kind == "subresource" { descrStr := strings.TrimPrefix(descr, ": ") if op.Subresource != "" { // Ссылаемся на отдельную страницу subresource — она будет создана в writeResourceDocs subFile := fmt.Sprintf("%s_%s.md", spec.Name, slug(op.Subresource)) b.WriteString(fmt.Sprintf("- `%s` \u2014 %s. \u0421\u043c. [nubes_%s_%s](%s)\n", name, descrStr, spec.Name, slug(op.Subresource), subFile)) } else { // Нет subresource-имени — выводим инлайн if descrStr != "" { b.WriteString(fmt.Sprintf("\n#### `%s` \u2014 %s\n\n", name, descrStr)) } else { b.WriteString(fmt.Sprintf("\n#### `%s`\n\n", name)) } if len(op.Params) > 0 { b.WriteString(renderParamTable(op.Params, true, true)) b.WriteString("\n") } } continue } switch op.Action { case "create": b.WriteString(fmt.Sprintf("- `%s` \u2014 \u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0430. \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b: [%s params](%s_params_create.md)\n", name, "Create", spec.Name)) case "modify": b.WriteString(fmt.Sprintf("- `%s` \u2014 \u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0430. \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name)) default: if descr != "" { b.WriteString(fmt.Sprintf("- `%s` \u2014 %s\n", name, strings.TrimPrefix(descr, ": "))) } else { b.WriteString(fmt.Sprintf("- `%s`\n", name)) } } } return b.String() } func buildParamsLandingPage(spec ServiceSpec, nav string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav)) b.WriteString("## Parameters\n\n") b.WriteString(fmt.Sprintf("- [Create params](%s_params_create.md)\n", spec.Name)) b.WriteString(fmt.Sprintf("- [Modify params](%s_params_modify.md)\n", spec.Name)) return b.String() } func findParams(ops []OperationSpec, action string) []ParamSpec { for _, op := range ops { if op.Kind == "instance" && op.Action == action { return op.Params } } return nil } func splitParams(params []ParamSpec) ([]ParamSpec, []ParamSpec) { required := []ParamSpec{} defaults := []ParamSpec{} for _, p := range params { if hasDefault(p.Default) { defaults = append(defaults, p) continue } required = append(required, p) } return required, defaults } func hasDefault(value interface{}) bool { if value == nil { return false } if s, ok := value.(string); ok { return strings.TrimSpace(s) != "" } return true } func renderParamTable(params []ParamSpec, requiredTable, noDefault bool) string { if len(params) == 0 { return "None.\n" } var b strings.Builder tableClass := "resource-table resource-table-compact" if requiredTable { tableClass += " resource-table-required" } b.WriteString(fmt.Sprintf("\n", tableClass)) if noDefault { b.WriteString("\n\n") } else { b.WriteString("\n\n") } for _, p := range params { b.WriteString("") b.WriteString(fmt.Sprintf("", escapeText(formatID(p.ID)))) b.WriteString(fmt.Sprintf("", formatParamCode(p.Code))) b.WriteString(fmt.Sprintf("", formatTypeCell(p))) if !noDefault { b.WriteString(fmt.Sprintf("", defaultCell(p.Default))) } b.WriteString(fmt.Sprintf("", escapeText(pickTextTable(p)))) b.WriteString(fmt.Sprintf("", escapeText(collectConstraints(p)))) b.WriteString("\n") } b.WriteString("
IDCodeTypeDescriptionConstraints
IDCodeTypeDefaultDescriptionConstraints
%s%s%s%s%s%s
\n") return b.String() } func renderModifyTable(params []ParamSpec) string { if len(params) == 0 { return "None.\n" } var b strings.Builder b.WriteString("\n") b.WriteString("\n\n") for _, p := range params { b.WriteString("") b.WriteString(fmt.Sprintf("", escapeText(formatID(p.ID)))) b.WriteString(fmt.Sprintf("", formatParamCode(p.Code))) b.WriteString(fmt.Sprintf("", formatTypeCell(p))) b.WriteString(fmt.Sprintf("", escapeText(pickTextTable(p)))) b.WriteString(fmt.Sprintf("", escapeText(collectConstraints(p)))) b.WriteString("\n") } b.WriteString("
IDCodeTypeDescriptionConstraints
%s%s%s%s%s
\n") return b.String() } func formatParamLine(p ParamSpec, indent string, requiredOnly bool) string { if requiredOnly && !p.Required { return "" } value := sampleValue(p) comment := strings.TrimSpace(stripHTML(p.Descr)) if comment == "" { comment = strings.TrimSpace(stripHTML(p.Man)) } paramCode := toSnake(p.Code) if comment != "" { return fmt.Sprintf("%s%s = %s # %s\n", indent, paramCode, value, comment) } return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value) } func sampleValue(p ParamSpec) string { if hasDefault(p.Default) { return formatLiteral(p.Default) } if len(p.ValueList) > 0 { return formatLiteral(p.ValueList[0]) } dtype := strings.ToLower(firstType(p)) switch { case strings.Contains(dtype, "bool"): return "false" case strings.Contains(dtype, "int") || strings.Contains(dtype, "number"): return "1" case strings.Contains(dtype, "json"): return "{}" } return "\"TODO\"" } func formatLiteral(value interface{}) string { switch v := value.(type) { case bool: if v { return "true" } return "false" case int: return strconv.Itoa(v) case int64: return strconv.FormatInt(v, 10) case float64: return strconv.FormatFloat(v, 'f', -1, 64) case string: if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") { return v } if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") { return v } if looksLikeNumber(v) || v == "true" || v == "false" { return v } return fmt.Sprintf("\"%s\"", v) default: return fmt.Sprintf("\"%v\"", v) } } func looksLikeNumber(value string) bool { if value == "" { return false } if value[0] == '-' { value = value[1:] } for _, ch := range value { if ch == '.' { continue } if ch < '0' || ch > '9' { return false } } return true } func formatID(id int) string { if id == 0 { return "" } return strconv.Itoa(id) } func formatTypeCell(p ParamSpec) string { dtype := firstType(p) if dtype == "" { return "" } return "" + escapeText(dtype) + "" } func firstType(p ParamSpec) string { if p.DataType != "" { return normalizeType(p.DataType) } return normalizeType(p.Type) } func normalizeType(dtype string) string { cleaned := strings.ReplaceAll(dtype, ">", ">") if idx := strings.Index(cleaned, ">"); idx >= 0 { cleaned = cleaned[:idx] } return strings.TrimSpace(cleaned) } func defaultCell(value interface{}) string { if !hasDefault(value) { return "" } if s, ok := value.(string); ok { return "" + escapeText(s) + "" } return "" + escapeText(formatLiteral(value)) + "" } func pickTextTable(p ParamSpec) string { if p.Man != "" { return p.Man } return p.Descr } func outputDescription(code string) string { switch code { case "state_params": return "\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043d\u044b\u0435 \u0432 API \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438/\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438" case "state_out": return "\u043e\u0442\u0432\u0435\u0442 API \u0441 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043c\u0438/\u0432\u044b\u0445\u043e\u0434\u043d\u044b\u043c\u0438 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043c\u0438" case "state_params_flat": return "\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0432 \u043f\u043b\u043e\u0441\u043a\u043e\u043c \u0432\u0438\u0434\u0435 (\u043a\u043b\u044e\u0447\u0438 \u0441 \u043f\u0443\u0442\u044f\u043c\u0438)" case "state_out_flat": return "\u043e\u0442\u0432\u0435\u0442\u044b \u0432 \u043f\u043b\u043e\u0441\u043a\u043e\u043c \u0432\u0438\u0434\u0435 (\u043a\u043b\u044e\u0447\u0438 \u0441 \u043f\u0443\u0442\u044f\u043c\u0438)" case "vault_secrets": return "\u0441\u0435\u043a\u0440\u0435\u0442\u044b, \u0437\u0430\u043f\u0438\u0441\u0430\u043d\u043d\u044b\u0435 \u0432 Vault \u0434\u043b\u044f \u0440\u0435\u0441\u0443\u0440\u0441\u0430" case "vault_url": return "\u0430\u0434\u0440\u0435\u0441 Vault \u0434\u043b\u044f \u0440\u0435\u0441\u0443\u0440\u0441\u0430" case "vault_user_path": return "\u043f\u0443\u0442\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0432 Vault" case "vault_fields": return "\u0441\u043f\u0438\u0441\u043e\u043a \u043a\u043b\u044e\u0447\u0435\u0439 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u0435\u043a\u0440\u0435\u0442\u043e\u0432" default: return "" } } func formatBoolTitle(value bool) string { if value { return "True" } return "False" } func collectConstraints(p ParamSpec) string { parts := []string{} if p.MinValue != nil { parts = append(parts, fmt.Sprintf("minvalue=%d", *p.MinValue)) } if p.MaxValue != nil { parts = append(parts, fmt.Sprintf("maxvalue=%d", *p.MaxValue)) } if p.Regex != "" { parts = append(parts, fmt.Sprintf("regex=%s", p.Regex)) } if len(p.ValueList) > 0 { parts = append(parts, fmt.Sprintf("value_list=%s", strings.Join(p.ValueList, ", "))) } if p.Func != "" { parts = append(parts, fmt.Sprintf("func=%s", p.Func)) } if p.RefSvcID != nil { parts = append(parts, fmt.Sprintf("ref_svc_id=%d", *p.RefSvcID)) } if p.Unique != "" { parts = append(parts, fmt.Sprintf("unique_scope=%s", p.Unique)) } if p.MaxLength != nil { parts = append(parts, fmt.Sprintf("maxlength=%d", *p.MaxLength)) } if p.MinLength != nil { parts = append(parts, fmt.Sprintf("minlength=%d", *p.MinLength)) } return strings.Join(parts, "; ") } func escapeText(value string) string { if value == "" { return "" } text := html.EscapeString(value) text = strings.ReplaceAll(text, "<br/>", "
") text = strings.ReplaceAll(text, "<br />", "
") text = strings.ReplaceAll(text, "<br>", "
") return text } func formatCode(code string) string { if code == "" { return "" } if len(code) <= 40 { return "" + code + "" } out := []string{} prev := rune(0) for _, ch := range code { if ch == '_' { out = append(out, "_") prev = ch continue } if prev != 0 && (unicodeIsLower(prev) || unicodeIsDigit(prev)) && unicodeIsUpper(ch) { out = append(out, "") } out = append(out, string(ch)) prev = ch } return "" + strings.Join(out, "") + "" } func formatParamCode(code string) string { return formatCode(toSnake(code)) } func toSnake(value string) string { if value == "" { return "" } var out []rune lastUnderscore := false prevLowerOrDigit := false for _, r := range value { if r >= 'A' && r <= 'Z' { if prevLowerOrDigit && !lastUnderscore { out = append(out, '_') } out = append(out, r+'a'-'A') lastUnderscore = false prevLowerOrDigit = false continue } if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { out = append(out, r) lastUnderscore = false prevLowerOrDigit = true continue } if !lastUnderscore && len(out) > 0 { out = append(out, '_') lastUnderscore = true } prevLowerOrDigit = false } return strings.Trim(string(out), "_") } func unicodeIsLower(r rune) bool { return r >= 'a' && r <= 'z' } func unicodeIsUpper(r rune) bool { return r >= 'A' && r <= 'Z' } func unicodeIsDigit(r rune) bool { return r >= '0' && r <= '9' } func stripHTML(value string) string { if value == "" { return "" } re := regexp.MustCompile("<[^>]+>") cleaned := re.ReplaceAllString(value, " ") cleaned = strings.Join(strings.Fields(cleaned), " ") return html.UnescapeString(cleaned) } func slug(value string) string { out := []rune{} for _, ch := range value { if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' { out = append(out, ch) } else if ch >= 'A' && ch <= 'Z' { out = append(out, ch+'a'-'A') } else { out = append(out, '_') } } return strings.Trim(string(out), "_") } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func escapeCode(value string) string { buf := bytes.NewBuffer(nil) for _, line := range strings.Split(value, "\n") { buf.WriteString(html.EscapeString(line)) buf.WriteString("\n") } return buf.String() } // generateIndexMD writes index.md with the list of all processed services. // The file is written to docsDir so each profile gets its own stand-specific index. func generateIndexMD(docsDir string, specs []ServiceSpec) { var b bytes.Buffer b.WriteString("# Ресурсы провайдера\n\n") b.WriteString("| ID | Ресурс | Описание |\n") b.WriteString("|-----|--------|----------|\n") for _, spec := range specs { resName := "nubes_" + spec.Name link := fmt.Sprintf("[%s](%s.md)", resName, spec.Name) descr := strings.ReplaceAll(spec.ServiceDisplayName, "|", "\\|") b.WriteString(fmt.Sprintf("| %d | %s | %s |\n", spec.ServiceID, link, descr)) } outPath := filepath.Join(docsDir, "index.md") if err := os.WriteFile(outPath, b.Bytes(), 0o644); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write index.md: %v\n", err) } } // capitalize делает первую букву строки заглавной. func capitalize(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + s[1:] } // findSubresourceParams возвращает параметры операции subresource по названию и action. func findSubresourceParams(ops []OperationSpec, srName, action string) []ParamSpec { for _, op := range ops { if op.Kind == "subresource" && op.Subresource == srName && op.Action == action { return op.Params } } return nil } // buildSubresourcePage генерирует страницу документации для subresource (nubes_{service}_{subresource}). func buildSubresourcePage(spec ServiceSpec, srName string, nav string) string { var b strings.Builder srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, slug(srName)) b.WriteString(fmt.Sprintf("# Resource %s\n\n", srResourceName)) b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID)) b.WriteString(nav + "\n\n") // Create params createParams := findSubresourceParams(spec.Operations, srName, "create") b.WriteString("## Create params\n\n") if len(createParams) > 0 { req, def := splitParams(createParams) b.WriteString("**Обязательные параметры**\n\n") b.WriteString(renderParamTable(req, true, true)) if len(def) > 0 { b.WriteString("\n**Параметры со значением по умолчанию**\n\n") b.WriteString(renderParamTable(def, false, false)) } } else { b.WriteString("None.\n") } // Modify params (если есть) modifyParams := findSubresourceParams(spec.Operations, srName, "modify") if len(modifyParams) > 0 { b.WriteString("\n## Modify params\n\n") b.WriteString(renderModifyTable(modifyParams)) } // Delete params deleteParams := findSubresourceParams(spec.Operations, srName, "delete") b.WriteString("\n## Delete params\n\n") if len(deleteParams) > 0 { b.WriteString(renderParamTable(deleteParams, true, true)) } else { b.WriteString("None.\n") } return b.String() } // buildSubresourceExamplePage генерирует страницу с HCL-примером для subresource. func buildSubresourceExamplePage(spec ServiceSpec, srName string, nav string, version string, apiEndpoint 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(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint)) 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() }