refactor: merge ops-generator into docs-generator as --ops mode

Single binary, single go.mod — no divergence risk.
Run: docs-generator --ops (or regular docs-generator for resource docs)
This commit is contained in:
“Naeel”
2026-07-05 10:28:59 +04:00
parent 46e847a3c1
commit 6a1b243366
6 changed files with 71 additions and 123 deletions
+157
View File
@@ -0,0 +1,157 @@
// Package ops — генерация per-service operations документации.
package ops
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// Entry — запись в индексе документации.
type Entry struct {
ServiceID int
Name string
File string
Title string
OpCount int
}
// ServiceDoc генерирует Markdown-документацию операций сервиса.
func ServiceDoc(path string, spec ServiceOpsSpec) error {
var b strings.Builder
b.WriteString(fmt.Sprintf("# Operations for nubes_%s\n\n", spec.Name))
b.WriteString(fmt.Sprintf("Service ID: `%d`\n\n", spec.ServiceID))
if t := Title(spec); t != "" {
b.WriteString(fmt.Sprintf("Service: %s\n\n", t))
}
if strings.TrimSpace(spec.ServiceMan) != "" {
b.WriteString("## UI description\n\n")
b.WriteString(spec.ServiceMan)
b.WriteString("\n\n")
}
b.WriteString("## Operations\n\n")
b.WriteString("Non-CRUD operations are typically executed via an action resource.\n")
b.WriteString("Domain operations (for example create_user, create_database) are best modeled as separate resources.\n\n")
if len(spec.Operations) == 0 {
b.WriteString("No operations found.\n")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
for _, op := range spec.Operations {
b.WriteString(fmt.Sprintf("- `%s` (id: %d)\n", op.Name, op.ID))
}
b.WriteString("\n")
for _, op := range spec.Operations {
b.WriteString(fmt.Sprintf("## Operation: %s\n\n", op.Name))
b.WriteString(fmt.Sprintf("Operation ID: `%d`\n\n", op.ID))
if strings.TrimSpace(op.Man) != "" {
b.WriteString(op.Man)
b.WriteString("\n\n")
}
if len(op.Params) == 0 {
b.WriteString("No parameters.\n\n")
continue
}
b.WriteString("| Code | Type | Required | Default | ID | RefSvcId | ValueList | Func | Regex | Min | Max | Sensitive | DependsOn |\n")
b.WriteString("|---|---|---|---|---|---|---|---|---|---|---|---|---|\n")
for _, p := range op.Params {
b.WriteString(fmt.Sprintf("| `%s` | `%s` | `%t` | `%s` | `%d` | `%s` | `%s` | `%s` | `%s` | `%s` | `%s` | `%t` | `%s` |\n",
Esc(p.Code),
Esc(p.DataType),
p.Required,
Esc(Value(p.Default)),
p.ID,
Esc(Ref(p.RefSvcId)),
Esc(List(p.ValueList)),
Esc(p.Func),
Esc(p.Regex),
Esc(Value(p.MinValue)),
Esc(Value(p.MaxValue)),
p.IsSensitive,
Esc(Value(p.DependsOn)),
))
}
b.WriteString("\n")
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// Index генерирует index.md со списком всех операций.
func Index(path string, entries []Entry) error {
var b strings.Builder
b.WriteString("# Operations by service\n\n")
b.WriteString("Auto-generated list of available operations per service.\n\n")
b.WriteString("Note: non-CRUD operations should be invoked via an action resource,\n")
b.WriteString("and domain operations are best represented as dedicated resources.\n\n")
for _, e := range entries {
b.WriteString(fmt.Sprintf("- %d - nubes_%s (%d ops): [%s](%s)\n", e.ServiceID, e.Name, e.OpCount, e.Title, e.File))
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// Name нормализует имя файла.
func Name(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
name = strings.ReplaceAll(name, " ", "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, ":", "_")
name = strings.ReplaceAll(name, "-", "_")
return name
}
// Title возвращает отображаемое имя сервиса.
func Title(spec ServiceOpsSpec) string {
if strings.TrimSpace(spec.ServiceDisplayName) != "" {
return spec.ServiceDisplayName
}
if strings.TrimSpace(spec.ServiceShortName) != "" {
return spec.ServiceShortName
}
return ""
}
// List форматирует список значений.
func List(items []string) string {
if len(items) == 0 {
return ""
}
return strings.Join(items, ",")
}
// Ref форматирует RefSvcId.
func Ref(value *int) string {
if value == nil {
return ""
}
return fmt.Sprintf("%d", *value)
}
// Value форматирует значение параметра.
func Value(value interface{}) string {
if value == nil {
return ""
}
switch t := value.(type) {
case string:
return t
default:
b, err := json.Marshal(t)
if err != nil {
return fmt.Sprintf("%v", value)
}
return string(b)
}
}
// Esc экранирует pipe-символы для Markdown-таблиц.
func Esc(value string) string {
return strings.ReplaceAll(value, "|", "\\|")
}
@@ -0,0 +1,44 @@
// Package ops — структуры данных для ops-документации (операции сервисов).
package ops
// ServiceOpsSpec — YAML-спек операций сервиса.
type ServiceOpsSpec struct {
Name string `yaml:"name"`
ServiceID int `yaml:"service_id"`
ServiceDisplayName string `yaml:"service_display_name,omitempty"`
ServiceShortName string `yaml:"service_short_name,omitempty"`
ServiceMan string `yaml:"service_man,omitempty"`
Operations []OperationSpec `yaml:"operations"`
}
// OperationSpec — одна операция.
type OperationSpec struct {
Name string `yaml:"name"`
ID int `yaml:"id"`
Man string `yaml:"man,omitempty"`
Params []ParamSpec `yaml:"params"`
}
// ParamSpec — параметр операции.
type ParamSpec struct {
ID int `yaml:"id"`
Code string `yaml:"code"`
DataType string `yaml:"data_type,omitempty"`
Required bool `yaml:"required"`
Default interface{} `yaml:"default,omitempty"`
ValueList []string `yaml:"value_list,omitempty"`
RefSvcId *int `yaml:"ref_svc_id,omitempty"`
Func string `yaml:"func,omitempty"`
Regex string `yaml:"regex,omitempty"`
UniqueScope string `yaml:"unique_scope,omitempty"`
MaxLength *int `yaml:"maxlength,omitempty"`
MinLength *int `yaml:"minlength,omitempty"`
MaxValue interface{} `yaml:"maxvalue,omitempty"`
MinValue interface{} `yaml:"minvalue,omitempty"`
Descr string `yaml:"descr,omitempty"`
Man string `yaml:"man,omitempty"`
Sort *int `yaml:"sort,omitempty"`
DependsOn interface{} `yaml:"depends_on,omitempty"`
IsModifiable *bool `yaml:"is_modifiable,omitempty"`
IsSensitive bool `yaml:"is_sensitive,omitempty"`
}
+65
View File
@@ -14,6 +14,7 @@ import (
"gopkg.in/yaml.v3"
"docs-generator/internal/ops"
"docs-generator/internal/types"
"docs-generator/internal/writers"
)
@@ -25,9 +26,16 @@ func main() {
excludeFlag := flag.String("exclude", "", "Comma-separated resource names to skip")
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")
opsFlag := flag.Bool("ops", false, "Generate per-service operations docs (resources_ops_yaml → docs/.../operations)")
flag.Parse()
root := detectRoot()
// --ops mode: generate per-service operations documentation
if *opsFlag {
runOpsMode(root)
return
}
resourcesDir := pickPath(*resourcesDirFlag, filepath.Join(root, "provider", "resources_yaml"))
docsDir := pickPath(*docsDirFlag, filepath.Join(root, "docs", "30_registry", "resources"))
servicesList := pickPath(*servicesListFlag, filepath.Join(root, "devops", "config", "services_list.txt"))
@@ -171,3 +179,60 @@ func loadSpecs(dir string, ordered []types.ServiceMeta) []types.ServiceSpec {
}
return specs
}
// runOpsMode генерирует per-service operations документацию.
func runOpsMode(root string) {
yamlDir := pickPath(os.Getenv("NUBES_OPS_YAML_DIR"), filepath.Join(root, "provider", "resources_ops_yaml"))
docsDir := pickPath(os.Getenv("NUBES_OPS_DOCS_DIR"), filepath.Join(root, "docs", "30_registry", "resources", "operations"))
if err := os.MkdirAll(docsDir, 0o755); err != nil {
panic(err)
}
entries := []ops.Entry{}
err := filepath.WalkDir(yamlDir, 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 ops.ServiceOpsSpec
if err := yaml.Unmarshal(b, &spec); err != nil {
return err
}
if spec.ServiceID == 0 || spec.Name == "" {
return nil
}
fileName := fmt.Sprintf("%d_%s.md", spec.ServiceID, ops.Name(spec.Name))
outPath := filepath.Join(docsDir, fileName)
if err := ops.ServiceDoc(outPath, spec); err != nil {
return err
}
entries = append(entries, ops.Entry{
ServiceID: spec.ServiceID,
Name: spec.Name,
File: fileName,
Title: ops.Title(spec),
OpCount: len(spec.Operations),
})
return nil
})
if err != nil {
panic(err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].ServiceID < entries[j].ServiceID })
if err := ops.Index(filepath.Join(docsDir, "index.md"), entries); err != nil {
panic(err)
}
fmt.Printf("Generated %d operations docs in %s\n", len(entries), docsDir)
}