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("
\n")
b.WriteString(man)
b.WriteString("\n
\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\n")
b.WriteString("\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f (\u043a\u0440\u0430\u0442\u043a\u043e)
\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`.
\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:
\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).
\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.
\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`:
\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.
\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).
\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("
\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("\n", tableClass))
if noDefault {
b.WriteString("| ID | Code | Type | Description | Constraints |
\n\n")
} else {
b.WriteString("| ID | Code | Type | Default | Description | Constraints |
\n\n")
}
for _, p := range params {
b.WriteString("")
b.WriteString(fmt.Sprintf("| %s | ", escapeText(formatID(p.ID))))
b.WriteString(fmt.Sprintf("%s | ", formatParamCode(p.Code)))
b.WriteString(fmt.Sprintf("%s | ", formatTypeCell(p)))
if !noDefault {
b.WriteString(fmt.Sprintf("%s | ", defaultCell(p.Default)))
}
b.WriteString(fmt.Sprintf("%s | ", escapeText(pickTextTable(p))))
b.WriteString(fmt.Sprintf("%s | ", escapeText(collectConstraints(p))))
b.WriteString("
\n")
}
b.WriteString("
\n")
return b.String()
}
func renderModifyTable(params []ParamSpec) string {
if len(params) == 0 {
return "None.\n"
}
var b strings.Builder
b.WriteString("\n")
b.WriteString("| ID | Code | Type | Description | Constraints |
\n\n")
for _, p := range params {
b.WriteString("")
b.WriteString(fmt.Sprintf("| %s | ", escapeText(formatID(p.ID))))
b.WriteString(fmt.Sprintf("%s | ", formatParamCode(p.Code)))
b.WriteString(fmt.Sprintf("%s | ", formatTypeCell(p)))
b.WriteString(fmt.Sprintf("%s | ", escapeText(pickTextTable(p))))
b.WriteString(fmt.Sprintf("%s | ", escapeText(collectConstraints(p))))
b.WriteString("
\n")
}
b.WriteString("
\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 "" + escapeText(dtype) + ""
}
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, ">", ">")
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 "" + escapeText(s) + ""
}
return "" + escapeText(formatLiteral(value)) + ""
}
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, "<br/>", "
")
text = strings.ReplaceAll(text, "<br />", "
")
text = strings.ReplaceAll(text, "<br>", "
")
return text
}
func formatCode(code string) string {
if code == "" {
return ""
}
if len(code) <= 40 {
return "" + code + ""
}
out := []string{}
prev := rune(0)
for _, ch := range code {
if ch == '_' {
out = append(out, "_")
prev = ch
continue
}
if prev != 0 && (unicodeIsLower(prev) || unicodeIsDigit(prev)) && unicodeIsUpper(ch) {
out = append(out, "")
}
out = append(out, string(ch))
prev = ch
}
return "" + strings.Join(out, "") + ""
}
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()
}