- Rename directory - Update all 9 devops scripts - Update 4 generator source files - Rebuild all binaries
860 lines
28 KiB
Go
860 lines
28 KiB
Go
// 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) {
|
||
base := spec.Name
|
||
nav := fmt.Sprintf("[Manual](%s.md) | [Create params](%s_params_create.md) | [Modify params](%s_params_modify.md) | [Output params](%s_outputs.md) | [Operations](%s_ops.md) | [Example](%s_example.md)", base, base, base, base, base, base)
|
||
|
||
WriteFile(filepath.Join(docsDir, base+".md"), buildManualPage(spec, nav))
|
||
WriteFile(filepath.Join(docsDir, base+"_example.md"), buildExamplePage(spec, nav, version, apiEndpoint))
|
||
WriteFile(filepath.Join(docsDir, base+"_params_create.md"), buildCreateParamsPage(spec, nav))
|
||
WriteFile(filepath.Join(docsDir, base+"_params_modify.md"), buildModifyParamsPage(spec, nav))
|
||
WriteFile(filepath.Join(docsDir, base+"_outputs.md"), buildOutputsPage(spec, nav))
|
||
WriteFile(filepath.Join(docsDir, base+"_ops.md"), buildOpsPage(spec, nav, docsDir))
|
||
WriteFile(filepath.Join(docsDir, base+"_params.md"), buildParamsLandingPage(spec, nav))
|
||
|
||
for _, srName := range CollectSubresources(spec) {
|
||
srBase := fmt.Sprintf("%s_%s", base, Slug(srName))
|
||
srNav := fmt.Sprintf("[%s (основной)](%s.md) | [Operations](%s_ops.md) | [%s](%s.md) | [Example](%s_example.md)",
|
||
spec.ServiceDisplayName, base, base, Capitalize(srName), srBase, srBase)
|
||
WriteFile(filepath.Join(docsDir, srBase+".md"), buildSubresourcePage(spec, srName, srNav))
|
||
WriteFile(filepath.Join(docsDir, srBase+"_example.md"), buildSubresourceExamplePage(spec, srName, srNav, version, apiEndpoint))
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
var b bytes.Buffer
|
||
b.WriteString("# Ресурсы провайдера\n\n")
|
||
b.WriteString("| ID | Ресурс | Описание |\n")
|
||
b.WriteString("|-----|--------|----------|\n")
|
||
for _, spec := range specs {
|
||
resName := "nubes_" + spec.Name
|
||
link := fmt.Sprintf("[%s](%s.md)", resName, spec.Name)
|
||
descr := strings.ReplaceAll(spec.ServiceDisplayName, "|", "\\|")
|
||
b.WriteString(fmt.Sprintf("| %d | %s | %s |\n", spec.ServiceID, link, descr))
|
||
}
|
||
outPath := filepath.Join(docsDir, "index.md")
|
||
if err := os.WriteFile(outPath, b.Bytes(), 0o644); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Warning: failed to write index.md: %v\n", err)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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) string {
|
||
name := spec.ServiceDisplayName
|
||
if name == "" {
|
||
name = spec.Name
|
||
}
|
||
return fmt.Sprintf("# Resource nubes_%s\n\nService ID: `%d`\n\nService Name: %s\n\n%s\n\n", spec.Name, spec.ServiceID, name, nav)
|
||
}
|
||
|
||
func buildManualPage(spec types.ServiceSpec, nav string) string {
|
||
var b strings.Builder
|
||
b.WriteString(buildHeader(spec, nav))
|
||
b.WriteString("## MAN\n\n")
|
||
man := strings.TrimSpace(spec.ServiceMan)
|
||
if man == "" {
|
||
man = "Manual not available."
|
||
}
|
||
b.WriteString("<div class=\"man-content\">\n")
|
||
b.WriteString(man)
|
||
b.WriteString("\n</div>\n")
|
||
return b.String()
|
||
}
|
||
|
||
func buildExamplePage(spec types.ServiceSpec, nav, version string, apiEndpoint string) string {
|
||
var b strings.Builder
|
||
b.WriteString(buildHeader(spec, nav))
|
||
b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_main.tf`)\n\n", spec.Name))
|
||
b.WriteString("```hcl\n")
|
||
b.WriteString(exampleBlock(spec, version, apiEndpoint))
|
||
b.WriteString("```\n\n")
|
||
b.WriteString("## Outputs usage\n\n")
|
||
b.WriteString("```hcl\n")
|
||
b.WriteString("# Выходные параметры можно использовать как\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) string {
|
||
createParams := FindParams(spec.Operations, "create")
|
||
requiredParams, defaultParams := SplitParams(createParams)
|
||
|
||
var b strings.Builder
|
||
b.WriteString("terraform {\n")
|
||
b.WriteString(" required_providers {\n")
|
||
b.WriteString(" nubes = {\n")
|
||
b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n")
|
||
b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version))
|
||
b.WriteString(" }\n")
|
||
b.WriteString(" }\n")
|
||
b.WriteString("}\n\n")
|
||
b.WriteString("# Токен доступа api_token к Nubes API — замените на реальный.\n")
|
||
b.WriteString("provider \"nubes\" {\n")
|
||
b.WriteString(" api_token = \"token_***\"\n")
|
||
b.WriteString(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint))
|
||
b.WriteString("}\n")
|
||
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
|
||
|
||
for _, p := range requiredParams {
|
||
b.WriteString(formatParamLine(p, " ", true))
|
||
}
|
||
|
||
if len(defaultParams) > 0 {
|
||
b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n")
|
||
for _, p := range defaultParams {
|
||
b.WriteString(formatParamLine(p, " ", false))
|
||
}
|
||
}
|
||
|
||
b.WriteString("}\n")
|
||
return b.String()
|
||
}
|
||
|
||
func buildCreateParamsPage(spec types.ServiceSpec, nav string) string {
|
||
var b strings.Builder
|
||
b.WriteString(buildHeader(spec, nav))
|
||
b.WriteString("## Create params\n\n")
|
||
createParams := FindParams(spec.Operations, "create")
|
||
requiredParams, defaultParams := SplitParams(createParams)
|
||
|
||
b.WriteString("**Обязательные параметры (вводимые пользователем)**\n\n")
|
||
b.WriteString(renderParamTable(requiredParams, true, true))
|
||
|
||
b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n")
|
||
b.WriteString(renderParamTable(defaultParams, false, false))
|
||
|
||
lifecycle := spec.Lifecycle
|
||
if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault {
|
||
b.WriteString("\n<div class=\"lifecycle-note\">\n")
|
||
b.WriteString("<strong>Параметры поведения (кратко)</strong><br/>\n")
|
||
b.WriteString(fmt.Sprintf("По умолчанию: `suspend_on_destroy = %s`, `adopt_existing_on_create = %s`.<br/>\n", formatBoolTitle(lifecycle.SuspendOnDestroyDefault), formatBoolTitle(lifecycle.AdoptExistingOnCreateDefault)))
|
||
b.WriteString("При `terraform destroy` или удалении ресурса из манифеста:<br/>\n")
|
||
b.WriteString("- `suspend_on_destroy=true` — инстанс переводится в `Suspend` (не удаляется).<br/>\n")
|
||
b.WriteString("- `suspend_on_destroy=false` — Terraform удаляет ресурс только из state.<br/>\n")
|
||
b.WriteString("При `apply` флаг `adopt_existing_on_create` работает как авто-`import`:<br/>\n")
|
||
b.WriteString("- `false` — если ресурс уже есть, будет ошибка.<br/>\n")
|
||
b.WriteString("- `true` — Terraform может взять существующий инстанс под управление (`running` → adopt, `suspended` → resume+adopt при совпадении параметров).<br/>\n")
|
||
b.WriteString("Важно: один инстанс должен быть только в одном state. Иначе получите конфликт управления.\n")
|
||
b.WriteString("</div>\n")
|
||
}
|
||
|
||
return b.String()
|
||
}
|
||
|
||
func buildModifyParamsPage(spec types.ServiceSpec, nav string) string {
|
||
var b strings.Builder
|
||
b.WriteString(buildHeader(spec, nav))
|
||
b.WriteString("## Modify params\n\n")
|
||
modifyParams := FindParams(spec.Operations, "modify")
|
||
b.WriteString(renderModifyTable(modifyParams))
|
||
return b.String()
|
||
}
|
||
|
||
func buildOutputsPage(spec types.ServiceSpec, nav string) string {
|
||
var b strings.Builder
|
||
b.WriteString(buildHeader(spec, nav))
|
||
b.WriteString("## Output params\n\n")
|
||
if len(spec.Outputs.Params) == 0 {
|
||
b.WriteString("None.\n")
|
||
return b.String()
|
||
}
|
||
for _, p := range spec.Outputs.Params {
|
||
desc := outputDescription(p.Code)
|
||
if desc != "" {
|
||
b.WriteString(fmt.Sprintf("- `%s` (%s) — %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, docsDir string) string {
|
||
var b strings.Builder
|
||
b.WriteString(buildHeader(spec, nav))
|
||
b.WriteString("## Operations\n\n")
|
||
for _, op := range spec.Operations {
|
||
name := op.Name
|
||
if name == "" {
|
||
name = op.Action
|
||
}
|
||
descr := strings.TrimSpace(stripHTML(op.Man))
|
||
if descr != "" {
|
||
descr = ": " + descr
|
||
}
|
||
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) string {
|
||
var b strings.Builder
|
||
b.WriteString(buildHeader(spec, nav))
|
||
b.WriteString("## Parameters\n\n")
|
||
b.WriteString(fmt.Sprintf("- [Create params](%s_params_create.md)\n", spec.Name))
|
||
b.WriteString(fmt.Sprintf("- [Modify params](%s_params_modify.md)\n", spec.Name))
|
||
return b.String()
|
||
}
|
||
|
||
func buildSubresourcePage(spec types.ServiceSpec, srName string, nav string) string {
|
||
var b strings.Builder
|
||
srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, Slug(srName))
|
||
b.WriteString(fmt.Sprintf("# Resource %s\n\n", srResourceName))
|
||
b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID))
|
||
b.WriteString(nav + "\n\n")
|
||
|
||
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) string {
|
||
var b strings.Builder
|
||
srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, Slug(srName))
|
||
b.WriteString(fmt.Sprintf("# Resource %s — Example\n\n", srResourceName))
|
||
b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID))
|
||
b.WriteString(nav + "\n\n")
|
||
b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_%s_main.tf`)\n\n", spec.Name, Slug(srName)))
|
||
b.WriteString("```hcl\n")
|
||
|
||
b.WriteString("terraform {\n")
|
||
b.WriteString(" required_providers {\n")
|
||
b.WriteString(" nubes = {\n")
|
||
b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n")
|
||
b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version))
|
||
b.WriteString(" }\n")
|
||
b.WriteString(" }\n")
|
||
b.WriteString("}\n\n")
|
||
|
||
b.WriteString("provider \"nubes\" {\n")
|
||
b.WriteString(" api_token = \"token_***\"\n")
|
||
b.WriteString(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint))
|
||
b.WriteString("}\n\n")
|
||
|
||
b.WriteString(fmt.Sprintf("# Родительский ресурс — nubes_%s должен быть создан заранее\n", spec.Name))
|
||
b.WriteString(fmt.Sprintf("# resource \"nubes_%s\" \"baza\" { ... }\n\n", spec.Name))
|
||
|
||
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
|
||
tableClass := "resource-table resource-table-compact"
|
||
if requiredTable {
|
||
tableClass += " resource-table-required"
|
||
}
|
||
b.WriteString(fmt.Sprintf("<table class=\"%s\">\n", tableClass))
|
||
if noDefault {
|
||
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
|
||
} else {
|
||
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Default</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
|
||
}
|
||
for _, p := range params {
|
||
b.WriteString("<tr>")
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(p.ID))))
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", formatParamCode(p.Code)))
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(p)))
|
||
if !noDefault {
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", defaultCell(p.Default)))
|
||
}
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickTextTable(p))))
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(p))))
|
||
b.WriteString("</tr>\n")
|
||
}
|
||
b.WriteString("</tbody></table>\n")
|
||
return b.String()
|
||
}
|
||
|
||
func renderModifyTable(params []types.ParamSpec) string {
|
||
if len(params) == 0 {
|
||
return "None.\n"
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("<table class=\"resource-table resource-table-compact\">\n")
|
||
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
|
||
for _, p := range params {
|
||
b.WriteString("<tr>")
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(p.ID))))
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", formatParamCode(p.Code)))
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(p)))
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickTextTable(p))))
|
||
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(p))))
|
||
b.WriteString("</tr>\n")
|
||
}
|
||
b.WriteString("</tbody></table>\n")
|
||
return b.String()
|
||
}
|
||
|
||
func formatParamLine(p types.ParamSpec, indent string, requiredOnly bool) string {
|
||
if requiredOnly && !p.Required {
|
||
return ""
|
||
}
|
||
value := sampleValue(p)
|
||
comment := strings.TrimSpace(stripHTML(p.Descr))
|
||
if comment == "" {
|
||
comment = strings.TrimSpace(stripHTML(p.Man))
|
||
}
|
||
paramCode := ToSnake(p.Code)
|
||
if comment != "" {
|
||
return fmt.Sprintf("%s%s = %s # %s\n", indent, paramCode, value, comment)
|
||
}
|
||
return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value)
|
||
}
|
||
|
||
func sampleValue(p types.ParamSpec) string {
|
||
if hasDefault(p.Default) {
|
||
return formatLiteral(p.Default)
|
||
}
|
||
if len(p.ValueList) > 0 {
|
||
return formatLiteral(p.ValueList[0])
|
||
}
|
||
dtype := strings.ToLower(firstType(p))
|
||
switch {
|
||
case strings.Contains(dtype, "bool"):
|
||
return "false"
|
||
case strings.Contains(dtype, "int") || strings.Contains(dtype, "number"):
|
||
return "1"
|
||
case strings.Contains(dtype, "json"):
|
||
return "{}"
|
||
}
|
||
return "\"TODO\""
|
||
}
|
||
|
||
func formatLiteral(value interface{}) string {
|
||
switch v := value.(type) {
|
||
case bool:
|
||
if v {
|
||
return "true"
|
||
}
|
||
return "false"
|
||
case int:
|
||
return strconv.Itoa(v)
|
||
case int64:
|
||
return strconv.FormatInt(v, 10)
|
||
case float64:
|
||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||
case string:
|
||
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
|
||
return v
|
||
}
|
||
if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") {
|
||
return v
|
||
}
|
||
if looksLikeNumber(v) || v == "true" || v == "false" {
|
||
return v
|
||
}
|
||
return fmt.Sprintf("\"%s\"", v)
|
||
default:
|
||
return fmt.Sprintf("\"%v\"", v)
|
||
}
|
||
}
|
||
|
||
func looksLikeNumber(value string) bool {
|
||
if value == "" {
|
||
return false
|
||
}
|
||
if value[0] == '-' {
|
||
value = value[1:]
|
||
}
|
||
for _, ch := range value {
|
||
if ch == '.' {
|
||
continue
|
||
}
|
||
if ch < '0' || ch > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func formatID(id int) string {
|
||
if id == 0 {
|
||
return ""
|
||
}
|
||
return strconv.Itoa(id)
|
||
}
|
||
|
||
func formatTypeCell(p types.ParamSpec) string {
|
||
dtype := firstType(p)
|
||
if dtype == "" {
|
||
return ""
|
||
}
|
||
return "<code>" + escapeText(dtype) + "</code>"
|
||
}
|
||
|
||
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 defaultCell(value interface{}) string {
|
||
if !hasDefault(value) {
|
||
return ""
|
||
}
|
||
if s, ok := value.(string); ok {
|
||
return "<code>" + escapeText(s) + "</code>"
|
||
}
|
||
return "<code>" + escapeText(formatLiteral(value)) + "</code>"
|
||
}
|
||
|
||
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("value_list=%s", strings.Join(p.ValueList, ", ")))
|
||
}
|
||
if p.Func != "" {
|
||
parts = append(parts, fmt.Sprintf("func=%s", p.Func))
|
||
}
|
||
if p.RefSvcID != nil {
|
||
parts = append(parts, fmt.Sprintf("ref_svc_id=%d", *p.RefSvcID))
|
||
}
|
||
if p.Unique != "" {
|
||
parts = append(parts, fmt.Sprintf("unique_scope=%s", p.Unique))
|
||
}
|
||
if p.MaxLength != nil {
|
||
parts = append(parts, fmt.Sprintf("maxlength=%d", *p.MaxLength))
|
||
}
|
||
if p.MinLength != nil {
|
||
parts = append(parts, fmt.Sprintf("minlength=%d", *p.MinLength))
|
||
}
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
|
||
func escapeText(value string) string {
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
text := html.EscapeString(value)
|
||
text = strings.ReplaceAll(text, "<br/>", "<br/>")
|
||
text = strings.ReplaceAll(text, "<br />", "<br/>")
|
||
text = strings.ReplaceAll(text, "<br>", "<br/>")
|
||
return text
|
||
}
|
||
|
||
func formatParamCode(code string) string {
|
||
return formatCode(ToSnake(code))
|
||
}
|
||
|
||
func formatCode(code string) string {
|
||
if code == "" {
|
||
return ""
|
||
}
|
||
if len(code) <= 40 {
|
||
return "<strong><code class=\"code-no-wrap\">" + code + "</code></strong>"
|
||
}
|
||
out := []string{}
|
||
prev := rune(0)
|
||
for _, ch := range code {
|
||
if ch == '_' {
|
||
out = append(out, "_<wbr>")
|
||
prev = ch
|
||
continue
|
||
}
|
||
if prev != 0 && (unicodeIsLower(prev) || unicodeIsDigit(prev)) && unicodeIsUpper(ch) {
|
||
out = append(out, "<wbr>")
|
||
}
|
||
out = append(out, string(ch))
|
||
prev = ch
|
||
}
|
||
return "<strong><code class=\"code-wrap\">" + strings.Join(out, "") + "</code></strong>"
|
||
}
|
||
|
||
// 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 unicodeIsLower(r rune) bool { return r >= 'a' && r <= 'z' }
|
||
func unicodeIsUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
|
||
func unicodeIsDigit(r rune) bool { return r >= '0' && r <= '9' }
|
||
|
||
func stripHTML(value string) string {
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
re := regexp.MustCompile("<[^>]+>")
|
||
cleaned := re.ReplaceAllString(value, " ")
|
||
cleaned = strings.Join(strings.Fields(cleaned), " ")
|
||
return html.UnescapeString(cleaned)
|
||
}
|
||
|
||
// 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
|
||
}
|