Files
tf_provider/TOOLS/docs-generator/main.go
T
“Naeel” a9491e13ea refactor: extract all generators into independent TOOLS/ modules
Each generator is now a standalone Go module with its own go.mod:

TOOLS/
├── yaml-generator/       ← service_spec_gen (API → YAML)
├── resource-generator/   ← gen_v2 (YAML → Go resources)
├── docs-generator/       ← docs_template_gen_v2 (YAML → Markdown)
├── ops-generator/        ← ops_docs_gen (ops documentation)
└── bin/                  ← pre-built binaries

All scripts updated to use TOOLS/bin/* binaries.
Removed old universal_rebuild/tools/ and universal_rebuild/bin/.
2026-07-05 09:57:13 +04:00

1100 lines
38 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 main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"html"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
type ParamSpec struct {
ID int `yaml:"id"`
Code string `yaml:"code"`
DataType string `yaml:"data_type"`
Type string `yaml:"type"`
Required bool `yaml:"required"`
Default interface{} `yaml:"default"`
Descr string `yaml:"descr"`
Man string `yaml:"man"`
RefSvcID *int `yaml:"ref_svc_id"`
Func string `yaml:"func"`
MinValue *int `yaml:"minvalue"`
MaxValue *int `yaml:"maxvalue"`
Regex string `yaml:"regex"`
ValueList []string `yaml:"value_list"`
Unique string `yaml:"unique_scope"`
MaxLength *int `yaml:"maxlength"`
MinLength *int `yaml:"minlength"`
}
type OperationSpec struct {
Name string `yaml:"name"`
ID int `yaml:"id"`
Kind string `yaml:"kind"`
Action string `yaml:"action"`
Subresource string `yaml:"subresource"`
Man string `yaml:"man"`
Params []ParamSpec `yaml:"params"`
}
type OutputParam struct {
Code string `yaml:"code"`
Type string `yaml:"type"`
Sensitive bool `yaml:"sensitive"`
}
type ServiceSpec struct {
Name string `yaml:"name"`
ServiceID int `yaml:"service_id"`
ServiceDisplayName string `yaml:"service_display_name"`
ServiceMan string `yaml:"service_man"`
Lifecycle struct {
SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"`
AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"`
} `yaml:"lifecycle"`
Outputs struct {
Params []OutputParam `yaml:"params"`
} `yaml:"outputs"`
Operations []OperationSpec `yaml:"operations"`
}
type ServiceMeta struct {
ID int
Name string
}
type CloudOutputSnapshot struct {
ServiceID int `json:"serviceId"`
ServiceName string `json:"serviceName"`
OutPaths []string `json:"out_paths"`
VaultFields []string `json:"vault_fields"`
}
var cloudOutputByServiceID = map[int]CloudOutputSnapshot{}
func main() {
resourcesDirFlag := flag.String("resources", "", "Path to resources_yaml")
docsDirFlag := flag.String("docs", "", "Path to docs/30_registry/resources")
servicesListFlag := flag.String("services", "", "Path to devops/config/services_list.txt")
excludeFlag := flag.String("exclude", "", "Comma-separated resource names to skip (e.g. clickhouse)")
versionFlag := flag.String("version", "", "Provider version for example block")
apiEndpointFlag := flag.String("api-endpoint", "https://deck-api.ngcloud.ru/api/v1/index.cfm", "API endpoint for example block")
flag.Parse()
root := detectRoot()
resourcesDir := pickPath(*resourcesDirFlag, filepath.Join(root, "universal_rebuild", "resources_yaml"))
docsDir := pickPath(*docsDirFlag, filepath.Join(root, "docs", "30_registry", "resources"))
servicesList := pickPath(*servicesListFlag, filepath.Join(root, "devops", "config", "services_list.txt"))
cloudOutputByServiceID = loadCloudOutputSnapshot(root)
excludeSet := toSet(*excludeFlag)
version := *versionFlag
if version == "" {
version = detectVersion(filepath.Join(root, "universal_rebuild", "main.go"))
}
apiEndpoint := *apiEndpointFlag
servicesOrder := loadServicesList(servicesList)
specs := loadSpecs(resourcesDir, servicesOrder)
if len(specs) == 0 {
fmt.Fprintf(os.Stderr, "No specs found in %s\n", resourcesDir)
os.Exit(1)
}
if err := os.MkdirAll(docsDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: cannot create docs dir %s: %v\n", docsDir, err)
os.Exit(1)
}
var processedSpecs []ServiceSpec
for _, spec := range specs {
if excludeSet[spec.Name] {
continue
}
writeResourceDocs(docsDir, spec, version, apiEndpoint)
processedSpecs = append(processedSpecs, spec)
}
generateIndexMD(docsDir, processedSpecs)
}
func detectRoot() string {
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: cannot get working directory: %v\n", err)
os.Exit(1)
}
if filepath.Base(cwd) == "universal_rebuild" {
return filepath.Dir(cwd)
}
return cwd
}
func pickPath(value, fallback string) string {
if value != "" {
return value
}
return fallback
}
func toSet(csv string) map[string]bool {
out := map[string]bool{}
for _, item := range strings.Split(csv, ",") {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
out[trimmed] = true
}
return out
}
func loadCloudOutputSnapshot(root string) map[int]CloudOutputSnapshot {
out := map[int]CloudOutputSnapshot{}
base := filepath.Join(root, "docs", "70_api")
entries, err := os.ReadDir(base)
if err != nil {
return out
}
latestDir := ""
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, "output_inventory_snapshot_") {
if name > latestDir {
latestDir = name
}
}
}
if latestDir == "" {
return out
}
path := filepath.Join(base, latestDir, "running_suspended_output_fields_for_docs.json")
b, err := os.ReadFile(path)
if err != nil {
return out
}
var items []CloudOutputSnapshot
if err := json.Unmarshal(b, &items); err != nil {
return out
}
for _, item := range items {
out[item.ServiceID] = item
}
return out
}
func detectVersion(mainPath string) string {
b, err := os.ReadFile(mainPath)
if err != nil {
return "2.x"
}
re := regexp.MustCompile(`version string\s*=\s*"([0-9.]+)"`)
match := re.FindStringSubmatch(string(b))
if len(match) > 1 {
return match[1]
}
return "2.x"
}
func loadServicesList(path string) []ServiceMeta {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(b), "\n")
out := []ServiceMeta{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
head := strings.SplitN(line, "#", 2)[0]
parts := strings.Fields(head)
if len(parts) < 2 {
continue
}
id, err := strconv.Atoi(parts[0])
if err != nil {
continue
}
out = append(out, ServiceMeta{ID: id, Name: parts[1]})
}
return out
}
func loadSpecs(dir string, ordered []ServiceMeta) []ServiceSpec {
specsByID := map[int]ServiceSpec{}
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
var spec ServiceSpec
if err := yaml.Unmarshal(b, &spec); err != nil {
return err
}
specsByID[spec.ServiceID] = spec
return nil
})
if walkErr != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to load specs from %s: %v\n", dir, walkErr)
os.Exit(1)
}
if len(ordered) == 0 {
specs := make([]ServiceSpec, 0, len(specsByID))
for _, spec := range specsByID {
specs = append(specs, spec)
}
sort.Slice(specs, func(i, j int) bool { return specs[i].ServiceID < specs[j].ServiceID })
return specs
}
specs := []ServiceSpec{}
for _, meta := range ordered {
spec, ok := specsByID[meta.ID]
if !ok {
continue
}
specs = append(specs, spec)
}
return specs
}
// collectSubresources возвращает уникальные subresource-имена сервиса.
func collectSubresources(spec ServiceSpec) []string {
seen := map[string]bool{}
var out []string
for _, op := range spec.Operations {
if op.Kind == "subresource" && op.Subresource != "" {
if !seen[op.Subresource] {
seen[op.Subresource] = true
out = append(out, op.Subresource)
}
}
}
return out
}
func writeResourceDocs(docsDir string, spec ServiceSpec, version string, apiEndpoint string) {
base := spec.Name
nav := fmt.Sprintf("[Manual](%s.md) | [Create params](%s_params_create.md) | [Modify params](%s_params_modify.md) | [Output params](%s_outputs.md) | [Operations](%s_ops.md) | [Example](%s_example.md)", base, base, base, base, base, base)
writeFile(filepath.Join(docsDir, base+".md"), buildManualPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_example.md"), buildExamplePage(spec, nav, version, apiEndpoint))
writeFile(filepath.Join(docsDir, base+"_params_create.md"), buildCreateParamsPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_params_modify.md"), buildModifyParamsPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_outputs.md"), buildOutputsPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_ops.md"), buildOpsPage(spec, nav, docsDir))
writeFile(filepath.Join(docsDir, base+"_params.md"), buildParamsLandingPage(spec, nav))
// Отдельные страницы для каждого subresource (nubes_{service}_{subresource})
for _, srName := range collectSubresources(spec) {
srBase := fmt.Sprintf("%s_%s", base, slug(srName))
srNav := fmt.Sprintf("[%s (основной)](%s.md) | [Operations](%s_ops.md) | [%s](%s.md) | [Example](%s_example.md)",
spec.ServiceDisplayName, base, base, capitalize(srName), srBase, srBase)
writeFile(filepath.Join(docsDir, srBase+".md"), buildSubresourcePage(spec, srName, srNav))
writeFile(filepath.Join(docsDir, srBase+"_example.md"), buildSubresourceExamplePage(spec, srName, srNav, version, apiEndpoint))
}
}
func writeFile(path, content string) {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: cannot write %s: %v\n", path, err)
os.Exit(1)
}
}
func buildHeader(spec ServiceSpec, nav string) string {
name := spec.ServiceDisplayName
if name == "" {
name = spec.Name
}
return fmt.Sprintf("# Resource nubes_%s\n\nService ID: `%d`\n\nService Name: %s\n\n%s\n\n", spec.Name, spec.ServiceID, name, nav)
}
func buildManualPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## MAN\n\n")
man := strings.TrimSpace(spec.ServiceMan)
if man == "" {
man = "Manual not available."
}
b.WriteString("<div class=\"man-content\">\n")
b.WriteString(man)
b.WriteString("\n</div>\n")
return b.String()
}
func buildExamplePage(spec ServiceSpec, nav, version string, apiEndpoint string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_main.tf`)\n\n", spec.Name))
b.WriteString("```hcl\n")
b.WriteString(exampleBlock(spec, version, apiEndpoint))
b.WriteString("```\n\n")
b.WriteString("## Outputs usage\n\n")
b.WriteString("```hcl\n")
b.WriteString("# \u0412\u044b\u0445\u043e\u0434\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043c\u043e\u0436\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043a\u0430\u043a\n")
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params_flat[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out_flat[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_secrets[\"\u0438\u043c\u044f_\u043a\u043b\u044e\u0447\u0430\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_url\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_user_path\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_fields\n", spec.Name))
b.WriteString("```\n")
return b.String()
}
func exampleBlock(spec ServiceSpec, version string, apiEndpoint string) string {
createParams := findParams(spec.Operations, "create")
requiredParams, defaultParams := splitParams(createParams)
var b strings.Builder
b.WriteString("terraform {\n")
b.WriteString(" required_providers {\n")
b.WriteString(" nubes = {\n")
b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n")
b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version))
b.WriteString(" }\n")
b.WriteString(" }\n")
b.WriteString("}\n\n")
b.WriteString("# \u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 api_token \u043a Nubes API \u2014 \u0437\u0430\u043c\u0435\u043d\u0438\u0442\u0435 \u043d\u0430 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0439.\n")
b.WriteString("provider \"nubes\" {\n")
b.WriteString(" api_token = \"token_***\"\n")
b.WriteString(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint))
b.WriteString("}\n")
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
for _, p := range requiredParams {
b.WriteString(formatParamLine(p, " ", true))
}
if len(defaultParams) > 0 {
b.WriteString("\n # \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u0438\u043c\u0435\u044e\u0449\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u0435\u0441\u043b\u0438 \u043d\u0435 \u043c\u0435\u043d\u044f\u0435\u0442\u0435 - \u044d\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043d\u0435 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u0440\u043e\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0432 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0435\n")
for _, p := range defaultParams {
b.WriteString(formatParamLine(p, " ", false))
}
}
b.WriteString("}\n")
return b.String()
}
func buildCreateParamsPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Create params\n\n")
createParams := findParams(spec.Operations, "create")
requiredParams, defaultParams := splitParams(createParams)
b.WriteString("**\u041e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b (\u0432\u0432\u043e\u0434\u0438\u043c\u044b\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u043c)**\n\n")
b.WriteString(renderParamTable(requiredParams, true, true))
b.WriteString("\n**\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u0438\u043c\u0435\u044e\u0449\u0438\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u0435\u0441\u043b\u0438 \u043d\u0435 \u043c\u0435\u043d\u044f\u0435\u0442\u0435 - \u044d\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043d\u0435 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u0440\u043e\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0432 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0435**\n")
b.WriteString(renderParamTable(defaultParams, false, false))
lifecycle := spec.Lifecycle
if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault {
b.WriteString("\n<div class=\"lifecycle-note\">\n")
b.WriteString("<strong>\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f (\u043a\u0440\u0430\u0442\u043a\u043e)</strong><br/>\n")
b.WriteString(fmt.Sprintf("\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: `suspend_on_destroy = %s`, `adopt_existing_on_create = %s`.<br/>\n", formatBoolTitle(lifecycle.SuspendOnDestroyDefault), formatBoolTitle(lifecycle.AdoptExistingOnCreateDefault)))
b.WriteString("\u041f\u0440\u0438 `terraform destroy` \u0438\u043b\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0438 \u0440\u0435\u0441\u0443\u0440\u0441\u0430 \u0438\u0437 \u043c\u0430\u043d\u0438\u0444\u0435\u0441\u0442\u0430:<br/>\n")
b.WriteString("- `suspend_on_destroy=true` \u2014 \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u043f\u0435\u0440\u0435\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0432 `Suspend` (\u043d\u0435 \u0443\u0434\u0430\u043b\u044f\u0435\u0442\u0441\u044f).<br/>\n")
b.WriteString("- `suspend_on_destroy=false` \u2014 Terraform \u0443\u0434\u0430\u043b\u044f\u0435\u0442 \u0440\u0435\u0441\u0443\u0440\u0441 \u0442\u043e\u043b\u044c\u043a\u043e \u0438\u0437 state.<br/>\n")
b.WriteString("\u041f\u0440\u0438 `apply` \u0444\u043b\u0430\u0433 `adopt_existing_on_create` \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043a\u0430\u043a \u0430\u0432\u0442\u043e-`import`:<br/>\n")
b.WriteString("- `false` \u2014 \u0435\u0441\u043b\u0438 \u0440\u0435\u0441\u0443\u0440\u0441 \u0443\u0436\u0435 \u0435\u0441\u0442\u044c, \u0431\u0443\u0434\u0435\u0442 \u043e\u0448\u0438\u0431\u043a\u0430.<br/>\n")
b.WriteString("- `true` \u2014 Terraform \u043c\u043e\u0436\u0435\u0442 \u0432\u0437\u044f\u0442\u044c \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0439 \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u043f\u043e\u0434 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 (`running` \u2192 adopt, `suspended` \u2192 resume+adopt \u043f\u0440\u0438 \u0441\u043e\u0432\u043f\u0430\u0434\u0435\u043d\u0438\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432).<br/>\n")
b.WriteString("\u0412\u0430\u0436\u043d\u043e: \u043e\u0434\u0438\u043d \u0438\u043d\u0441\u0442\u0430\u043d\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u043e\u0434\u043d\u043e\u043c state. \u0418\u043d\u0430\u0447\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f.\n")
b.WriteString("</div>\n")
}
return b.String()
}
func buildModifyParamsPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Modify params\n\n")
modifyParams := findParams(spec.Operations, "modify")
b.WriteString(renderModifyTable(modifyParams))
return b.String()
}
func buildOutputsPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Output params\n\n")
if len(spec.Outputs.Params) == 0 {
b.WriteString("None.\n")
return b.String()
}
for _, p := range spec.Outputs.Params {
desc := outputDescription(p.Code)
if desc != "" {
b.WriteString(fmt.Sprintf("- `%s` (%s) \u2014 %s\n", p.Code, p.Type, desc))
} else {
b.WriteString(fmt.Sprintf("- `%s` (%s)\n", p.Code, p.Type))
}
}
snapshot, ok := cloudOutputByServiceID[spec.ServiceID]
if !ok {
return b.String()
}
hasOut := len(snapshot.OutPaths) > 0
hasVault := len(snapshot.VaultFields) > 0
if !hasOut && !hasVault {
return b.String()
}
b.WriteString("\n## Реальные поля из облака (running/suspended snapshot)\n\n")
b.WriteString("Поля ниже получены из реальных инстансов этого сервиса в облаке.\n")
b.WriteString("Используйте их как готовые ключи для `state_out_flat` и `vault_secrets`.\n\n")
if hasOut {
b.WriteString("### `state_out_flat` ключи\n\n")
for _, key := range snapshot.OutPaths {
if strings.TrimSpace(key) == "" {
continue
}
b.WriteString(fmt.Sprintf("- `state_out_flat[\"%s\"]`\n", key))
}
b.WriteString("\n")
}
if hasVault {
b.WriteString("### `vault_secrets` ключи\n\n")
for _, key := range snapshot.VaultFields {
if strings.TrimSpace(key) == "" {
continue
}
b.WriteString(fmt.Sprintf("- `vault_secrets[\"%s\"]`\n", key))
}
b.WriteString("\n")
}
if spec.ServiceID == 90 && containsString(snapshot.OutPaths, "internalConnect.master") && containsString(snapshot.VaultFields, "adminUser") && containsString(snapshot.VaultFields, "adminPass") {
b.WriteString("### Пример для связки с Lucee/NodeJS\n\n")
b.WriteString("```hcl\n")
b.WriteString("testds_connectionString = \"jdbc:postgresql://${nubes_postgres.db2.state_out_flat[\"internalConnect.master\"]}:5432/postgres?sslmode=require\"\n")
b.WriteString("testds_username = nubes_postgres.db2.vault_secrets[\"adminUser\"]\n")
b.WriteString("testds_password = nubes_postgres.db2.vault_secrets[\"adminPass\"]\n")
b.WriteString("```\n")
}
return b.String()
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func buildOpsPage(spec ServiceSpec, nav, docsDir string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Operations\n\n")
for _, op := range spec.Operations {
name := op.Name
if name == "" {
name = op.Action
}
descr := strings.TrimSpace(stripHTML(op.Man))
if descr != "" {
descr = ": " + descr
}
// Subresource-операции проверяем ДО switch, чтобы create_user/create_database
// не попадали в case "create" основного ресурса
if op.Kind == "subresource" {
descrStr := strings.TrimPrefix(descr, ": ")
if op.Subresource != "" {
// Ссылаемся на отдельную страницу subresource — она будет создана в writeResourceDocs
subFile := fmt.Sprintf("%s_%s.md", spec.Name, slug(op.Subresource))
b.WriteString(fmt.Sprintf("- `%s` \u2014 %s. \u0421\u043c. [nubes_%s_%s](%s)\n", name, descrStr, spec.Name, slug(op.Subresource), subFile))
} else {
// Нет subresource-имени — выводим инлайн
if descrStr != "" {
b.WriteString(fmt.Sprintf("\n#### `%s` \u2014 %s\n\n", name, descrStr))
} else {
b.WriteString(fmt.Sprintf("\n#### `%s`\n\n", name))
}
if len(op.Params) > 0 {
b.WriteString(renderParamTable(op.Params, true, true))
b.WriteString("\n")
}
}
continue
}
switch op.Action {
case "create":
b.WriteString(fmt.Sprintf("- `%s` \u2014 \u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0430. \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b: [%s params](%s_params_create.md)\n", name, "Create", spec.Name))
case "modify":
b.WriteString(fmt.Sprintf("- `%s` \u2014 \u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0430. \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name))
default:
if descr != "" {
b.WriteString(fmt.Sprintf("- `%s` \u2014 %s\n", name, strings.TrimPrefix(descr, ": ")))
} else {
b.WriteString(fmt.Sprintf("- `%s`\n", name))
}
}
}
return b.String()
}
func buildParamsLandingPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Parameters\n\n")
b.WriteString(fmt.Sprintf("- [Create params](%s_params_create.md)\n", spec.Name))
b.WriteString(fmt.Sprintf("- [Modify params](%s_params_modify.md)\n", spec.Name))
return b.String()
}
func findParams(ops []OperationSpec, action string) []ParamSpec {
for _, op := range ops {
if op.Kind == "instance" && op.Action == action {
return op.Params
}
}
return nil
}
func splitParams(params []ParamSpec) ([]ParamSpec, []ParamSpec) {
required := []ParamSpec{}
defaults := []ParamSpec{}
for _, p := range params {
if hasDefault(p.Default) {
defaults = append(defaults, p)
continue
}
required = append(required, p)
}
return required, defaults
}
func hasDefault(value interface{}) bool {
if value == nil {
return false
}
if s, ok := value.(string); ok {
return strings.TrimSpace(s) != ""
}
return true
}
func renderParamTable(params []ParamSpec, requiredTable, noDefault bool) string {
if len(params) == 0 {
return "None.\n"
}
var b strings.Builder
tableClass := "resource-table resource-table-compact"
if requiredTable {
tableClass += " resource-table-required"
}
b.WriteString(fmt.Sprintf("<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 []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 ParamSpec, indent string, requiredOnly bool) string {
if requiredOnly && !p.Required {
return ""
}
value := sampleValue(p)
comment := strings.TrimSpace(stripHTML(p.Descr))
if comment == "" {
comment = strings.TrimSpace(stripHTML(p.Man))
}
paramCode := toSnake(p.Code)
if comment != "" {
return fmt.Sprintf("%s%s = %s # %s\n", indent, paramCode, value, comment)
}
return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value)
}
func sampleValue(p ParamSpec) string {
if hasDefault(p.Default) {
return formatLiteral(p.Default)
}
if len(p.ValueList) > 0 {
return formatLiteral(p.ValueList[0])
}
dtype := strings.ToLower(firstType(p))
switch {
case strings.Contains(dtype, "bool"):
return "false"
case strings.Contains(dtype, "int") || strings.Contains(dtype, "number"):
return "1"
case strings.Contains(dtype, "json"):
return "{}"
}
return "\"TODO\""
}
func formatLiteral(value interface{}) string {
switch v := value.(type) {
case bool:
if v {
return "true"
}
return "false"
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case string:
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
return v
}
if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") {
return v
}
if looksLikeNumber(v) || v == "true" || v == "false" {
return v
}
return fmt.Sprintf("\"%s\"", v)
default:
return fmt.Sprintf("\"%v\"", v)
}
}
func looksLikeNumber(value string) bool {
if value == "" {
return false
}
if value[0] == '-' {
value = value[1:]
}
for _, ch := range value {
if ch == '.' {
continue
}
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func formatID(id int) string {
if id == 0 {
return ""
}
return strconv.Itoa(id)
}
func formatTypeCell(p ParamSpec) string {
dtype := firstType(p)
if dtype == "" {
return ""
}
return "<code>" + escapeText(dtype) + "</code>"
}
func firstType(p ParamSpec) string {
if p.DataType != "" {
return normalizeType(p.DataType)
}
return normalizeType(p.Type)
}
func normalizeType(dtype string) string {
cleaned := strings.ReplaceAll(dtype, "&gt;", ">")
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 ParamSpec) string {
if p.Man != "" {
return p.Man
}
return p.Descr
}
func outputDescription(code string) string {
switch code {
case "state_params":
return "\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043d\u044b\u0435 \u0432 API \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438/\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438"
case "state_out":
return "\u043e\u0442\u0432\u0435\u0442 API \u0441 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043c\u0438/\u0432\u044b\u0445\u043e\u0434\u043d\u044b\u043c\u0438 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043c\u0438"
case "state_params_flat":
return "\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0432 \u043f\u043b\u043e\u0441\u043a\u043e\u043c \u0432\u0438\u0434\u0435 (\u043a\u043b\u044e\u0447\u0438 \u0441 \u043f\u0443\u0442\u044f\u043c\u0438)"
case "state_out_flat":
return "\u043e\u0442\u0432\u0435\u0442\u044b \u0432 \u043f\u043b\u043e\u0441\u043a\u043e\u043c \u0432\u0438\u0434\u0435 (\u043a\u043b\u044e\u0447\u0438 \u0441 \u043f\u0443\u0442\u044f\u043c\u0438)"
case "vault_secrets":
return "\u0441\u0435\u043a\u0440\u0435\u0442\u044b, \u0437\u0430\u043f\u0438\u0441\u0430\u043d\u043d\u044b\u0435 \u0432 Vault \u0434\u043b\u044f \u0440\u0435\u0441\u0443\u0440\u0441\u0430"
case "vault_url":
return "\u0430\u0434\u0440\u0435\u0441 Vault \u0434\u043b\u044f \u0440\u0435\u0441\u0443\u0440\u0441\u0430"
case "vault_user_path":
return "\u043f\u0443\u0442\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0432 Vault"
case "vault_fields":
return "\u0441\u043f\u0438\u0441\u043e\u043a \u043a\u043b\u044e\u0447\u0435\u0439 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u0435\u043a\u0440\u0435\u0442\u043e\u0432"
default:
return ""
}
}
func formatBoolTitle(value bool) string {
if value {
return "True"
}
return "False"
}
func collectConstraints(p ParamSpec) string {
parts := []string{}
if p.MinValue != nil {
parts = append(parts, fmt.Sprintf("minvalue=%d", *p.MinValue))
}
if p.MaxValue != nil {
parts = append(parts, fmt.Sprintf("maxvalue=%d", *p.MaxValue))
}
if p.Regex != "" {
parts = append(parts, fmt.Sprintf("regex=%s", p.Regex))
}
if len(p.ValueList) > 0 {
parts = append(parts, fmt.Sprintf("value_list=%s", strings.Join(p.ValueList, ", ")))
}
if p.Func != "" {
parts = append(parts, fmt.Sprintf("func=%s", p.Func))
}
if p.RefSvcID != nil {
parts = append(parts, fmt.Sprintf("ref_svc_id=%d", *p.RefSvcID))
}
if p.Unique != "" {
parts = append(parts, fmt.Sprintf("unique_scope=%s", p.Unique))
}
if p.MaxLength != nil {
parts = append(parts, fmt.Sprintf("maxlength=%d", *p.MaxLength))
}
if p.MinLength != nil {
parts = append(parts, fmt.Sprintf("minlength=%d", *p.MinLength))
}
return strings.Join(parts, "; ")
}
func escapeText(value string) string {
if value == "" {
return ""
}
text := html.EscapeString(value)
text = strings.ReplaceAll(text, "&lt;br/&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br /&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br&gt;", "<br/>")
return text
}
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>"
}
func formatParamCode(code string) string {
return formatCode(toSnake(code))
}
func toSnake(value string) string {
if value == "" {
return ""
}
var out []rune
lastUnderscore := false
prevLowerOrDigit := false
for _, r := range value {
if r >= 'A' && r <= 'Z' {
if prevLowerOrDigit && !lastUnderscore {
out = append(out, '_')
}
out = append(out, r+'a'-'A')
lastUnderscore = false
prevLowerOrDigit = false
continue
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
out = append(out, r)
lastUnderscore = false
prevLowerOrDigit = true
continue
}
if !lastUnderscore && len(out) > 0 {
out = append(out, '_')
lastUnderscore = true
}
prevLowerOrDigit = false
}
return strings.Trim(string(out), "_")
}
func unicodeIsLower(r rune) bool { return r >= 'a' && r <= 'z' }
func unicodeIsUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
func unicodeIsDigit(r rune) bool { return r >= '0' && r <= '9' }
func stripHTML(value string) string {
if value == "" {
return ""
}
re := regexp.MustCompile("<[^>]+>")
cleaned := re.ReplaceAllString(value, " ")
cleaned = strings.Join(strings.Fields(cleaned), " ")
return html.UnescapeString(cleaned)
}
func slug(value string) string {
out := []rune{}
for _, ch := range value {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
out = append(out, ch)
} else if ch >= 'A' && ch <= 'Z' {
out = append(out, ch+'a'-'A')
} else {
out = append(out, '_')
}
}
return strings.Trim(string(out), "_")
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func escapeCode(value string) string {
buf := bytes.NewBuffer(nil)
for _, line := range strings.Split(value, "\n") {
buf.WriteString(html.EscapeString(line))
buf.WriteString("\n")
}
return buf.String()
}
// generateIndexMD writes index.md with the list of all processed services.
// The file is written to docsDir so each profile gets its own stand-specific index.
func generateIndexMD(docsDir string, specs []ServiceSpec) {
var b bytes.Buffer
b.WriteString("# Ресурсы провайдера\n\n")
b.WriteString("| ID | Ресурс | Описание |\n")
b.WriteString("|-----|--------|----------|\n")
for _, spec := range specs {
resName := "nubes_" + spec.Name
link := fmt.Sprintf("[%s](%s.md)", resName, spec.Name)
descr := strings.ReplaceAll(spec.ServiceDisplayName, "|", "\\|")
b.WriteString(fmt.Sprintf("| %d | %s | %s |\n", spec.ServiceID, link, descr))
}
outPath := filepath.Join(docsDir, "index.md")
if err := os.WriteFile(outPath, b.Bytes(), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write index.md: %v\n", err)
}
}
// capitalize делает первую букву строки заглавной.
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// findSubresourceParams возвращает параметры операции subresource по названию и action.
func findSubresourceParams(ops []OperationSpec, srName, action string) []ParamSpec {
for _, op := range ops {
if op.Kind == "subresource" && op.Subresource == srName && op.Action == action {
return op.Params
}
}
return nil
}
// buildSubresourcePage генерирует страницу документации для subresource (nubes_{service}_{subresource}).
func buildSubresourcePage(spec ServiceSpec, srName string, nav string) string {
var b strings.Builder
srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, slug(srName))
b.WriteString(fmt.Sprintf("# Resource %s\n\n", srResourceName))
b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID))
b.WriteString(nav + "\n\n")
// Create params
createParams := findSubresourceParams(spec.Operations, srName, "create")
b.WriteString("## Create params\n\n")
if len(createParams) > 0 {
req, def := splitParams(createParams)
b.WriteString("**Обязательные параметры**\n\n")
b.WriteString(renderParamTable(req, true, true))
if len(def) > 0 {
b.WriteString("\n**Параметры со значением по умолчанию**\n\n")
b.WriteString(renderParamTable(def, false, false))
}
} else {
b.WriteString("None.\n")
}
// Modify params (если есть)
modifyParams := findSubresourceParams(spec.Operations, srName, "modify")
if len(modifyParams) > 0 {
b.WriteString("\n## Modify params\n\n")
b.WriteString(renderModifyTable(modifyParams))
}
// Delete params
deleteParams := findSubresourceParams(spec.Operations, srName, "delete")
b.WriteString("\n## Delete params\n\n")
if len(deleteParams) > 0 {
b.WriteString(renderParamTable(deleteParams, true, true))
} else {
b.WriteString("None.\n")
}
return b.String()
}
// buildSubresourceExamplePage генерирует страницу с HCL-примером для subresource.
func buildSubresourceExamplePage(spec ServiceSpec, srName string, nav string, version string, apiEndpoint string) string {
var b strings.Builder
srResourceName := fmt.Sprintf("nubes_%s_%s", spec.Name, slug(srName))
b.WriteString(fmt.Sprintf("# Resource %s — Example\n\n", srResourceName))
b.WriteString(fmt.Sprintf("Service: `nubes_%s` (ID: %d)\n\n", spec.Name, spec.ServiceID))
b.WriteString(nav + "\n\n")
b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_%s_main.tf`)\n\n", spec.Name, slug(srName)))
b.WriteString("```hcl\n")
// terraform block
b.WriteString("terraform {\n")
b.WriteString(" required_providers {\n")
b.WriteString(" nubes = {\n")
b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n")
b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version))
b.WriteString(" }\n")
b.WriteString(" }\n")
b.WriteString("}\n\n")
b.WriteString("provider \"nubes\" {\n")
b.WriteString(" api_token = \"token_***\"\n")
b.WriteString(fmt.Sprintf(" api_endpoint = \"%s\"\n", apiEndpoint))
b.WriteString("}\n\n")
// Основной ресурс (родитель) — ссылка
b.WriteString(fmt.Sprintf("# Родительский ресурс — nubes_%s должен быть создан заранее\n", spec.Name))
b.WriteString(fmt.Sprintf("# resource \"nubes_%s\" \"baza\" { ... }\n\n", spec.Name))
// Subresource
b.WriteString(fmt.Sprintf("resource \"%s\" \"example\" {\n", srResourceName))
b.WriteString(fmt.Sprintf(" %s_id = nubes_%s.baza.id\n", spec.Name, spec.Name))
createParams := findSubresourceParams(spec.Operations, srName, "create")
req, def := splitParams(createParams)
for _, p := range req {
b.WriteString(formatParamLine(p, " ", true))
}
if len(def) > 0 {
b.WriteString("\n # Параметры со значением по умолчанию\n")
for _, p := range def {
b.WriteString(formatParamLine(p, " ", false))
}
}
b.WriteString("}\n")
b.WriteString("```\n")
return b.String()
}