// Package writers — генерация Markdown-документации для ресурсов. package writers import ( "bytes" "encoding/json" "fmt" "html" "os" "path/filepath" "regexp" "strconv" "strings" "docs-generator/internal/types" ) // CloudOutputByServiceID глобальный кэш снепшотов облачных выходов. var CloudOutputByServiceID = map[int]types.CloudOutputSnapshot{} // ResourceDocs генерирует полный набор .md файлов для одного сервиса. func ResourceDocs(docsDir string, spec types.ServiceSpec, version string, apiEndpoint string, providerSource string) { base := spec.Name navManual := fmt.Sprintf("**Manual** · [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) navCreate := fmt.Sprintf("[Manual](%s.md) · **Create params** · [Modify params](%s_params_modify.md) · [Output params](%s_outputs.md) · [Operations](%s_ops.md) · [Example](%s_example.md)", base, base, base, base, base) navModify := fmt.Sprintf("[Manual](%s.md) · [Create params](%s_params_create.md) · **Modify params** · [Output params](%s_outputs.md) · [Operations](%s_ops.md) · [Example](%s_example.md)", base, base, base, base, base) navOutputs := fmt.Sprintf("[Manual](%s.md) · [Create params](%s_params_create.md) · [Modify params](%s_params_modify.md) · **Output params** · [Operations](%s_ops.md) · [Example](%s_example.md)", base, base, base, base, base) navOps := fmt.Sprintf("[Manual](%s.md) · [Create params](%s_params_create.md) · [Modify params](%s_params_modify.md) · [Output params](%s_outputs.md) · **Operations** · [Example](%s_example.md)", base, base, base, base, base) navExample := 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**", base, base, base, base, base) WriteFile(filepath.Join(docsDir, base+".md"), buildManualPage(spec, navManual, version)) WriteFile(filepath.Join(docsDir, base+"_example.md"), buildExamplePage(spec, navExample, version, apiEndpoint, providerSource)) WriteFile(filepath.Join(docsDir, base+"_params_create.md"), buildCreateParamsPage(spec, navCreate, version)) WriteFile(filepath.Join(docsDir, base+"_params_modify.md"), buildModifyParamsPage(spec, navModify, version)) WriteFile(filepath.Join(docsDir, base+"_outputs.md"), buildOutputsPage(spec, navOutputs, version)) WriteFile(filepath.Join(docsDir, base+"_ops.md"), buildOpsPage(spec, navOps, version, docsDir)) WriteFile(filepath.Join(docsDir, base+"_params.md"), buildParamsLandingPage(spec, navManual, version)) 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, version)) WriteFile(filepath.Join(docsDir, srBase+"_example.md"), buildSubresourceExamplePage(spec, srName, srNav, version, apiEndpoint, providerSource)) } } // CollectSubresources возвращает уникальные subresource-имена. func CollectSubresources(spec types.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 } // WriteFile пишет контент в файл. 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) } } // IndexMD генерирует index.md с группировкой по категориям. func IndexMD(docsDir string, specs []types.ServiceSpec) { catMap := map[string][]types.ServiceSpec{} for _, s := range specs { cat := serviceCategory(s.Name) catMap[cat] = append(catMap[cat], s) } var b bytes.Buffer b.WriteString("# Ресурсы провайдера\n\n") order := []string{"Базы данных", "Очереди", "Хранилище", "K8s", "VMware", "Приложения", "Сеть", "Другие"} for _, cat := range order { svcs := catMap[cat] if len(svcs) == 0 { continue } b.WriteString(fmt.Sprintf("## %s\n\n", cat)) b.WriteString("| ID | Ресурс | Описание |\n") b.WriteString("|-----|--------|----------|\n") for _, spec := range svcs { 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)) } b.WriteString("\n") } 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) } } // FindParams находит параметры операции по action. func FindParams(ops []types.OperationSpec, action string) []types.ParamSpec { for _, op := range ops { if op.Kind == "instance" && op.Action == action { return op.Params } } return nil } // SplitParams делит параметры на обязательные и со значением по умолчанию. func SplitParams(params []types.ParamSpec) ([]types.ParamSpec, []types.ParamSpec) { required := []types.ParamSpec{} defaults := []types.ParamSpec{} for _, p := range params { if hasDefault(p.Default) { defaults = append(defaults, p) continue } required = append(required, p) } return required, defaults } // FindSubresourceParams находит параметры subresource-операции. func FindSubresourceParams(ops []types.OperationSpec, srName, action string) []types.ParamSpec { for _, op := range ops { if op.Kind == "subresource" && op.Subresource == srName && op.Action == action { return op.Params } } return nil } // ===== internal helpers ===== func buildHeader(spec types.ServiceSpec, nav string, version string) string { name := spec.ServiceDisplayName if name == "" { name = spec.Name } return fmt.Sprintf("# Resource nubes_%s · v%s · Service ID: %d · Service Name: %s\n\n%s\n\n", spec.Name, version, spec.ServiceID, name, nav) } func buildManualPage(spec types.ServiceSpec, nav string, version string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav, version)) // Краткое описание name := spec.ServiceDisplayName if name == "" { name = spec.Name } b.WriteString(fmt.Sprintf("Сервис **%s** (`nubes_%s`). См. [Create params](%s_params_create.md) для списка параметров.\n\n", name, spec.Name, spec.Name)) // MAN в раскрывающемся блоке — DevOps читает если застрял man := strings.TrimSpace(spec.ServiceMan) if man != "" { b.WriteString("
\nСправка (MAN)\n\n") b.WriteString("
\n\n") b.WriteString(htmlToMarkdown(man)) b.WriteString("\n\n
\n") b.WriteString("
\n") } return b.String() } // htmlToMarkdown converts raw HTML to Markdown. func htmlToMarkdown(s string) string { //
or
→ newline s = regexp.MustCompile(``).ReplaceAllString(s, "\n") //

...

→ # ... s = regexp.MustCompile(`

(.*?)

`).ReplaceAllString(s, "# $1") //

...

→ ## ... s = regexp.MustCompile(`

(.*?)

`).ReplaceAllString(s, "## $1") //

...

→ ### ... s = regexp.MustCompile(`

(.*?)

`).ReplaceAllString(s, "### $1") // or → **...** s = regexp.MustCompile(`<(?:strong|b)>(.*?)`).ReplaceAllString(s, "**$1**") // or → *...* s = regexp.MustCompile(`<(?:em|i)>(.*?)`).ReplaceAllString(s, "*$1*") // ... → `...` s = regexp.MustCompile(`(.*?)`).ReplaceAllString(s, "`$1`") // ... → [...](...) s = regexp.MustCompile(`(.*?)`).ReplaceAllString(s, "[$2]($1)") //
    /
— remove s = regexp.MustCompile(``).ReplaceAllString(s, "") //
  • ...
  • → - ... s = regexp.MustCompile(`
  • (.*?)
  • `).ReplaceAllString(s, "- $1") //
      /
    — remove s = regexp.MustCompile(``).ReplaceAllString(s, "") //

    /

    — remove s = regexp.MustCompile(``).ReplaceAllString(s, "") //
     / 
    — remove (content stays) s = regexp.MustCompile(``).ReplaceAllString(s, "") //
    or
    → --- s = regexp.MustCompile(``).ReplaceAllString(s, "\n---\n") // Strip remaining HTML tags s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "") // Decode HTML entities s = html.UnescapeString(s) // Collapse multiple blank lines s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n") return strings.TrimSpace(s) } func buildExamplePage(spec types.ServiceSpec, nav string, version string, apiEndpoint string, providerSource string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav, version)) b.WriteString("## Минимальный пример (только обязательные параметры)\n\n") b.WriteString("```hcl\n") b.WriteString(minimalExampleBlock(spec, version, apiEndpoint, providerSource)) b.WriteString("```\n\n") b.WriteString("
    Полный пример (все параметры, включая значения по умолчанию)\n\n") b.WriteString("```hcl\n") b.WriteString(exampleBlock(spec, version, apiEndpoint, providerSource)) b.WriteString("```\n\n") b.WriteString("
    \n\n") b.WriteString("## Использование выходных данных\n\n") b.WriteString("```hcl\n") 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") // Реальные ключи из облака snapshot, ok := CloudOutputByServiceID[spec.ServiceID] if ok && (len(snapshot.OutPaths) > 0 || len(snapshot.VaultFields) > 0) { b.WriteString("\n### Реальные ключи из облака\n\n") b.WriteString("```hcl\n") for _, key := range snapshot.OutPaths { if strings.TrimSpace(key) == "" { continue } b.WriteString(fmt.Sprintf("# %s = nubes_%s.baza.state_out_flat[\"%s\"]\n", key, spec.Name, key)) } for _, key := range snapshot.VaultFields { if strings.TrimSpace(key) == "" { continue } b.WriteString(fmt.Sprintf("# %s = nubes_%s.baza.vault_secrets[\"%s\"]\n", key, spec.Name, key)) } b.WriteString("```\n") } return b.String() } func exampleBlock(spec types.ServiceSpec, version string, apiEndpoint string, providerSource 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(fmt.Sprintf(" source = \"%s\"\n", providerSource)) 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(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint)) b.WriteString("}\n") b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name)) b.WriteString(" resource_name = \"TODO\"\n\n") for _, p := range requiredParams { b.WriteString(formatParamOrBlock(p, " ", true)) } if len(defaultParams) > 0 { b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n") for _, p := range defaultParams { b.WriteString(formatParamOrBlock(p, " ", false)) } } b.WriteString("}\n") return b.String() } // minimalExampleBlock — только required параметры без default (минимальный работающий пример). func minimalExampleBlock(spec types.ServiceSpec, version string, apiEndpoint string, providerSource string) string { createParams := FindParams(spec.Operations, "create") var minimalParams []types.ParamSpec for _, p := range createParams { if p.Required && !hasDefault(p.Default) { minimalParams = append(minimalParams, p) } } var b strings.Builder b.WriteString("terraform {\n") b.WriteString(" required_providers {\n") b.WriteString(" nubes = {\n") b.WriteString(fmt.Sprintf(" source = \"%s\"\n", providerSource)) 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 = \"***\"\n") b.WriteString(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint)) b.WriteString("}\n") b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name)) b.WriteString(" resource_name = \"my-" + spec.Name + "\"\n\n") for _, p := range minimalParams { b.WriteString(formatParamOrBlock(p, " ", true)) } b.WriteString("}\n") return b.String() } func buildCreateParamsPage(spec types.ServiceSpec, nav string, version string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav, version)) b.WriteString("## Create params\n\n") createParams := FindParams(spec.Operations, "create") if len(createParams) > 0 { b.WriteString("| Параметр | Тип | Обязательный | По умолчанию | Описание | Ограничения |\n") b.WriteString("|----------|-----|-------------|-------------|----------|-------------|\n") for _, p := range createParams { code := formatParamCode(p.Code) dtype := formatTypeCell(p) req := "—" if p.Required { req = "**да**" } def := defaultCell(p.Default) desc := escapeText(pickTextTable(p)) // Якорь-ссылка для map-fixed/array-map-fixed с вложенными параметрами if p.HasSubParams && len(p.SubParams) > 0 && desc == "" { anchor := strings.ToLower(p.Code) if p.IsJson { desc = fmt.Sprintf("[Развернуть ↓](#%s-array-map-fixed)", anchor) } else { desc = fmt.Sprintf("[Развернуть ↓](#%s-map-fixed)", anchor) } } constr := escapeText(collectConstraints(p)) b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s |\n", code, dtype, req, def, desc, constr)) } b.WriteString("\n") // Вложенные параметры (map-fixed) for _, p := range createParams { b.WriteString(renderNestedParams(p)) } } lifecycle := spec.Lifecycle if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault { b.WriteString("\n!!! danger \"Важно: поведение при destroy\"\n") b.WriteString(fmt.Sprintf(" По умолчанию: `suspend_on_destroy = %s`, `adopt_existing_on_create = %s`.\n", formatBoolTitle(lifecycle.SuspendOnDestroyDefault), formatBoolTitle(lifecycle.AdoptExistingOnCreateDefault))) b.WriteString(" \n") b.WriteString(" При `terraform destroy` или удалении ресурса из манифеста:\n") b.WriteString(" - `suspend_on_destroy=true` — инстанс переводится в `Suspend` (не удаляется).\n") b.WriteString(" - `suspend_on_destroy=false` — Terraform удаляет ресурс только из state.\n") b.WriteString(" \n") b.WriteString(" При `apply` флаг `adopt_existing_on_create` работает как авто-`import`:\n") b.WriteString(" - `false` — если ресурс уже есть, будет ошибка.\n") b.WriteString(" - `true` — Terraform может взять существующий инстанс под управление.\n") b.WriteString(" \n") b.WriteString(" Важно: один инстанс должен быть только в одном state. Иначе получите конфликт управления.\n") b.WriteString("\n") } return b.String() } func buildModifyParamsPage(spec types.ServiceSpec, nav string, version string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav, version)) b.WriteString("## Modify params\n\n") modifyParams := FindParams(spec.Operations, "modify") b.WriteString(renderModifyTable(modifyParams)) return b.String() } func buildOutputsPage(spec types.ServiceSpec, nav string, version string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav, version)) 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) — %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 types.ServiceSpec, nav string, version string, docsDir string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav, version)) 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 } if op.Kind == "subresource" { descrStr := strings.TrimPrefix(descr, ": ") if op.Subresource != "" { subFile := fmt.Sprintf("%s_%s.md", spec.Name, Slug(op.Subresource)) b.WriteString(fmt.Sprintf("- `%s` — %s. См. [nubes_%s_%s](%s)\n", name, descrStr, spec.Name, Slug(op.Subresource), subFile)) } else { if descrStr != "" { b.WriteString(fmt.Sprintf("\n#### `%s` — %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` — Создание кластера. Параметры: [%s params](%s_params_create.md)\n", name, "Create", spec.Name)) case "modify": b.WriteString(fmt.Sprintf("- `%s` — Изменение кластера. Параметры: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name)) default: 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 types.ServiceSpec, nav string, version string) string { var b strings.Builder b.WriteString(buildHeader(spec, nav, version)) 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 buildSubresourcePage(spec types.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\n\n", srResourceName)) b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID)) 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") } modifyParams := FindSubresourceParams(spec.Operations, srName, "modify") if len(modifyParams) > 0 { b.WriteString("\n## Modify params\n\n") b.WriteString(renderModifyTable(modifyParams)) } 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() } func buildSubresourceExamplePage(spec types.ServiceSpec, srName string, nav string, version string, apiEndpoint string, providerSource 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(fmt.Sprintf("## Готовый манифест (`%s_%s_main.tf`)\n\n", spec.Name, Slug(srName))) b.WriteString("```hcl\n") b.WriteString("terraform {\n") b.WriteString(" required_providers {\n") b.WriteString(" nubes = {\n") b.WriteString(fmt.Sprintf(" source = \"%s\"\n", providerSource)) 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)) 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() } // ===== formatting helpers ===== func renderParamTable(params []types.ParamSpec, requiredTable, noDefault bool) string { if len(params) == 0 { return "None.\n" } var b strings.Builder if noDefault { b.WriteString("| Code | Type | Description | Constraints |\n") b.WriteString("|------|------|-------------|-------------|\n") } else { b.WriteString("| Code | Type | Default | Description | Constraints |\n") b.WriteString("|------|------|---------|-------------|-------------|\n") } for _, p := range params { code := formatParamCode(p.Code) dtype := formatTypeCell(p) desc := escapeText(pickTextTable(p)) constr := escapeText(collectConstraints(p)) if noDefault { b.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", code, dtype, desc, constr)) } else { def := defaultCell(p.Default) b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s |\n", code, dtype, def, desc, constr)) } } b.WriteString("\n") return b.String() } func renderModifyTable(params []types.ParamSpec) string { if len(params) == 0 { return "None.\n" } var b strings.Builder b.WriteString("| Code | Type | Description | Constraints |\n") b.WriteString("|------|------|-------------|-------------|\n") for _, p := range params { code := formatParamCode(p.Code) dtype := formatTypeCell(p) desc := escapeText(pickTextTable(p)) constr := escapeText(collectConstraints(p)) b.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", code, dtype, desc, constr)) } b.WriteString("\n") return b.String() } func formatParamLine(p types.ParamSpec, indent string, requiredOnly bool) string { if requiredOnly && !p.Required { return "" } value := sampleValue(p) if isJsonType(p) { value = fmt.Sprintf("jsonencode(%s)", value) } 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) } // formatParamOrBlock форматирует параметр или вложенный HCL-блок (map-fixed). func formatParamOrBlock(p types.ParamSpec, indent string, requiredOnly bool) string { if requiredOnly && !p.Required { return "" } // Для map-fixed генерируем вложенный HCL-блок if p.HasSubParams && len(p.SubParams) > 0 && !p.IsJson { paramCode := ToSnake(p.Code) var b strings.Builder b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode)) for _, sp := range p.SubParams { spVal := sampleValue(sp) if isJsonType(sp) { spVal = fmt.Sprintf("jsonencode(%s)", spVal) } spCode := ToSnake(sp.Code) spComment := strings.TrimSpace(stripHTML(sp.Descr)) if spComment == "" { spComment = strings.TrimSpace(stripHTML(sp.Man)) } if spComment != "" { b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment)) } else { b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal)) } } b.WriteString(fmt.Sprintf("%s}\n", indent)) return b.String() } // Для array-map-fixed генерируем dynamic блок if p.HasSubParams && len(p.SubParams) > 0 && p.IsJson { paramCode := ToSnake(p.Code) var b strings.Builder b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode)) b.WriteString(fmt.Sprintf("%s # Каждый элемент массива — объект с полями:\n", indent)) for _, sp := range p.SubParams { spVal := sampleValue(sp) if isJsonType(sp) { spVal = fmt.Sprintf("jsonencode(%s)", spVal) } spCode := ToSnake(sp.Code) spComment := strings.TrimSpace(stripHTML(sp.Descr)) if spComment == "" { spComment = strings.TrimSpace(stripHTML(sp.Man)) } if spComment != "" { b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment)) } else { b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal)) } } b.WriteString(fmt.Sprintf("%s}\n", indent)) return b.String() } return formatParamLine(p, indent, requiredOnly) } func sampleValue(p types.ParamSpec) string { if hasDefault(p.Default) { return formatLiteral(p.Default) } if len(p.ValueList) > 0 { return "\"TODO\"" } 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 types.ParamSpec) string { dtype := firstType(p) if dtype == "" { return "" } return "`" + dtype + "`" } func firstType(p types.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 isJsonType(p types.ParamSpec) bool { return strings.Contains(strings.ToLower(firstType(p)), "json") } func defaultCell(value interface{}) string { if !hasDefault(value) { return "" } if s, ok := value.(string); ok { return "`" + s + "`" } return "`" + formatLiteral(value) + "`" } func pickTextTable(p types.ParamSpec) string { if p.Man != "" { return p.Man } return p.Descr } func hasDefault(value interface{}) bool { if value == nil { return false } if s, ok := value.(string); ok { return strings.TrimSpace(s) != "" } return true } func outputDescription(code string) string { switch code { case "state_params": return "параметры, отправленные в API при создании/изменении" case "state_out": return "ответ API с результатами/выходными значениями" case "state_params_flat": return "параметры в плоском виде (ключи с путями)" case "state_out_flat": return "ответы в плоском виде (ключи с путями)" case "vault_secrets": return "секреты, записанные в Vault для ресурса" case "vault_url": return "адрес Vault для ресурса" case "vault_user_path": return "путь пользователя в Vault" case "vault_fields": return "список ключей доступных секретов" default: return "" } } func formatBoolTitle(value bool) string { if value { return "True" } return "False" } func collectConstraints(p types.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("Допустимые значения: %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>", "
    ") // Escape pipe for markdown tables text = strings.ReplaceAll(text, "|", "\\|") // Replace literal newlines with
    for markdown table cells text = strings.ReplaceAll(text, "\n", "
    ") return text } func formatParamCode(code string) string { snake := ToSnake(code) if snake == "" { return "" } return "**`" + snake + "`**" } // ToSnake конвертирует CamelCase → snake_case. 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 stripHTML(value string) string { if value == "" { return "" } re := regexp.MustCompile("<[^>]+>") cleaned := re.ReplaceAllString(value, " ") cleaned = strings.Join(strings.Fields(cleaned), " ") return html.UnescapeString(cleaned) } // Slug нормализует строку для использования в URL/имени файла. 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), "_") } // Capitalize делает первую букву заглавной. func Capitalize(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + s[1:] } // LoadCloudOutputSnapshot загружает снепшот облачных выходов. func LoadCloudOutputSnapshot(root string) map[int]types.CloudOutputSnapshot { out := map[int]types.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 []types.CloudOutputSnapshot if err := json.Unmarshal(b, &items); err != nil { return out } for _, item := range items { out[item.ServiceID] = item } return out } // renderNestedParams рендерит вложенные параметры (map-fixed/array-map-fixed). func renderNestedParams(p types.ParamSpec) string { if !p.HasSubParams || len(p.SubParams) == 0 { return "" } var b strings.Builder label := p.Code if p.IsJson { label += " (array-map-fixed) — элемент" } else { label += " (map-fixed)" } b.WriteString(fmt.Sprintf("\n### %s\n\n", label)) b.WriteString("| Code | Type | Required | Default | Description | Constraints |\n") b.WriteString("|------|------|----------|---------|-------------|-------------|\n") for _, sp := range p.SubParams { req := "no" if sp.Required { req = "**yes**" } code := formatParamCode(sp.Code) dtype := formatTypeCell(sp) def := defaultCell(sp.Default) desc := escapeText(pickTextTable(sp)) constr := escapeText(collectConstraints(sp)) b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s |\n", code, dtype, req, def, desc, constr)) } b.WriteString("\n") return b.String() } // serviceCategory возвращает категорию для сервиса. func serviceCategory(name string) string { cats := map[string]string{ "postgres": "Базы данных", "redis": "Базы данных", "mongodb": "Базы данных", "mariadb": "Базы данных", "clickhouse": "Базы данных", "rabbitmq": "Очереди", "kafka": "Очереди", "s3": "Хранилище", "s3bucket": "Хранилище", "nextcloud": "Хранилище", "k8s_velero": "K8s", "k8s_sthutrval_cluster": "K8s", "k8s_openbao": "K8s", "vc_mgmt_sthutrval_cluster": "K8s", "vc_org": "VMware", "vc_vdc": "VMware", "vc_nsxt": "VMware", "vcexternalip": "VMware", "vapp": "VMware", "vc_vm_v2": "VMware", "vc_vm_v3": "VMware", "vc_vdc_group": "VMware", "flask": "Приложения", "nodejs": "Приложения", "lucee": "Приложения", "http": "Приложения", "gitea": "Приложения", "superset": "Приложения", "pgadmin": "Приложения", "harbor": "Приложения", "akhq": "Приложения", "llm_ai": "Приложения", "template": "Приложения", "dummy": "Приложения", "valo_tenant": "Приложения", "zones_v2": "Сеть", "dnsrecord": "Сеть", } if cat, ok := cats[name]; ok { return cat } return "Другие" } // WriteNavFragment генерирует _nav_fragment.yml для mkdocs sidebar. func WriteNavFragment(docsDir string, specs []types.ServiceSpec) { catMap := map[string][]types.ServiceSpec{} for _, s := range specs { cat := serviceCategory(s.Name) catMap[cat] = append(catMap[cat], s) } var b bytes.Buffer b.WriteString("# auto-generated by docs-generator — DO NOT EDIT\n") b.WriteString("resources_nav:\n") order := []string{"Базы данных", "Очереди", "Хранилище", "K8s", "VMware", "Приложения", "Сеть", "Другие"} for _, cat := range order { svcs := catMap[cat] if len(svcs) == 0 { continue } b.WriteString(fmt.Sprintf(" - %s:\n", cat)) for _, s := range svcs { disp := s.ServiceDisplayName if disp == "" { disp = s.Name } b.WriteString(fmt.Sprintf(" - %s:\n", disp)) b.WriteString(fmt.Sprintf(" - Обзор: %s.md\n", s.Name)) b.WriteString(fmt.Sprintf(" - Параметры создания: %s_params_create.md\n", s.Name)) b.WriteString(fmt.Sprintf(" - Параметры изменения: %s_params_modify.md\n", s.Name)) b.WriteString(fmt.Sprintf(" - Выходные данные: %s_outputs.md\n", s.Name)) b.WriteString(fmt.Sprintf(" - Операции: %s_ops.md\n", s.Name)) b.WriteString(fmt.Sprintf(" - Пример: %s_example.md\n", s.Name)) } } outPath := filepath.Join(docsDir, "_nav_fragment.yml") if err := os.WriteFile(outPath, b.Bytes(), 0o644); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write _nav_fragment.yml: %v\n", err) } }