Files
tf_provider/TOOLS/docs-generator/internal/writers/writers.go
T

1122 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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("<details>\n<summary>Справка (MAN)</summary>\n\n")
b.WriteString("<div class=\"man-content\" markdown=\"1\">\n\n")
b.WriteString(htmlToMarkdown(man))
b.WriteString("\n\n</div>\n")
b.WriteString("</details>\n")
}
return b.String()
}
// htmlToMarkdown converts raw HTML to Markdown.
func htmlToMarkdown(s string) string {
// <br/> or <br> → newline
s = regexp.MustCompile(`<br\s*/?>`).ReplaceAllString(s, "\n")
// <h1>...</h1> → # ...
s = regexp.MustCompile(`<h1>(.*?)</h1>`).ReplaceAllString(s, "# $1")
// <h2>...</h2> → ## ...
s = regexp.MustCompile(`<h2>(.*?)</h2>`).ReplaceAllString(s, "## $1")
// <h3>...</h3> → ### ...
s = regexp.MustCompile(`<h3>(.*?)</h3>`).ReplaceAllString(s, "### $1")
// <strong> or <b> → **...**
s = regexp.MustCompile(`<(?:strong|b)>(.*?)</(?:strong|b)>`).ReplaceAllString(s, "**$1**")
// <em> or <i> → *...*
s = regexp.MustCompile(`<(?:em|i)>(.*?)</(?:em|i)>`).ReplaceAllString(s, "*$1*")
// <code>...</code> → `...`
s = regexp.MustCompile(`<code>(.*?)</code>`).ReplaceAllString(s, "`$1`")
// <a href="...">...</a> → [...](...)
s = regexp.MustCompile(`<a\s+href="(.*?)">(.*?)</a>`).ReplaceAllString(s, "[$2]($1)")
// <ul> / </ul> — remove
s = regexp.MustCompile(`</?ul>`).ReplaceAllString(s, "")
// <li>...</li> → - ...
s = regexp.MustCompile(`<li>(.*?)</li>`).ReplaceAllString(s, "- $1")
// <ol> / </ol> — remove
s = regexp.MustCompile(`</?ol>`).ReplaceAllString(s, "")
// <p> / </p> — remove
s = regexp.MustCompile(`</?p>`).ReplaceAllString(s, "")
// <pre> / </pre> — remove (content stays)
s = regexp.MustCompile(`</?pre>`).ReplaceAllString(s, "")
// <hr> or <hr/> → ---
s = regexp.MustCompile(`<hr\s*/?>`).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(fmt.Sprintf("## Minimal example — only required parameters\n\n"))
b.WriteString("```hcl\n")
b.WriteString(minimalExampleBlock(spec, version, apiEndpoint, providerSource))
b.WriteString("```\n\n")
b.WriteString("<details><summary>Full example (all parameters, including defaults)</summary>\n\n")
b.WriteString("```hcl\n")
b.WriteString(exampleBlock(spec, version, apiEndpoint, providerSource))
b.WriteString("```\n\n")
b.WriteString("</details>\n\n")
b.WriteString("## Outputs usage\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")
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-resource\"\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))
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("## Copy-ready manifest (`%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, "&gt;", ">")
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, "&lt;br/&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br /&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br&gt;", "<br/>")
// Escape pipe for markdown tables
text = strings.ReplaceAll(text, "|", "\\|")
// Replace literal newlines with <br/> for markdown table cells
text = strings.ReplaceAll(text, "\n", "<br/>")
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)
}
}