// 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)>(.*?)(?:strong|b)>`).ReplaceAllString(s, "**$1**")
// or → *...*
s = regexp.MustCompile(`<(?:em|i)>(.*?)(?:em|i)>`).ReplaceAllString(s, "*$1*")
// ... → `...`
s = regexp.MustCompile(`(.*?)`).ReplaceAllString(s, "`$1`")
// ... → [...](...)
s = regexp.MustCompile(`(.*?)`).ReplaceAllString(s, "[$2]($1)")
// — remove
s = regexp.MustCompile(`?ul>`).ReplaceAllString(s, "")
// ... → - ...
s = regexp.MustCompile(`(.*?)`).ReplaceAllString(s, "- $1")
// /
— remove
s = regexp.MustCompile(`?ol>`).ReplaceAllString(s, "")
// /
— remove
s = regexp.MustCompile(`?p>`).ReplaceAllString(s, "")
// /
— remove (content stays)
s = regexp.MustCompile(`?pre>`).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)
}
}