add: generator tools (sources)
This commit is contained in:
@@ -0,0 +1,744 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"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 {
|
||||||
|
DeleteModeDefault string `yaml:"delete_mode_default"`
|
||||||
|
ResumeIfExistsDefault bool `yaml:"resume_if_exists_default"`
|
||||||
|
} `yaml:"lifecycle"`
|
||||||
|
Outputs struct {
|
||||||
|
Params []OutputParam `yaml:"params"`
|
||||||
|
} `yaml:"outputs"`
|
||||||
|
Operations []OperationSpec `yaml:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceMeta struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "clickhouse", "Comma-separated resource names to skip")
|
||||||
|
versionFlag := flag.String("version", "", "Provider version 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"))
|
||||||
|
excludeSet := toSet(*excludeFlag)
|
||||||
|
version := *versionFlag
|
||||||
|
if version == "" {
|
||||||
|
version = detectVersion(filepath.Join(root, "universal_rebuild", "main.go"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spec := range specs {
|
||||||
|
if excludeSet[spec.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeResourceDocs(docsDir, spec, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectRoot() string {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
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 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 {
|
||||||
|
panic(walkErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResourceDocs(docsDir string, spec ServiceSpec, version 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))
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(path, content string) {
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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("<div class=\"copy-ready-code\"><pre><code>")
|
||||||
|
b.WriteString(exampleBlock(spec, version))
|
||||||
|
b.WriteString("</code></pre></div>\n\n")
|
||||||
|
b.WriteString("## Outputs usage\n\n")
|
||||||
|
b.WriteString("<div class=\"copy-ready-code\"><pre><code>")
|
||||||
|
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("</code></pre></div>\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func exampleBlock(spec ServiceSpec, version 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(" api_endpoint = \"https://deck-api.ngcloud.ru/api/v1/index.cfm\"\n")
|
||||||
|
b.WriteString("}\n")
|
||||||
|
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
|
||||||
|
|
||||||
|
if len(requiredParams) > 0 {
|
||||||
|
b.WriteString(" # TODO - обязательный параметр, задаваемый пользователем\n")
|
||||||
|
}
|
||||||
|
for _, p := range requiredParams {
|
||||||
|
b.WriteString(formatParamLine(p, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(defaultParams) > 0 {
|
||||||
|
b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n")
|
||||||
|
for _, p := range defaultParams {
|
||||||
|
b.WriteString(formatParamLine(p, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("}\n")
|
||||||
|
return escapeCode(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("**Обязательные параметры (вводимые пользователем)**\n\n")
|
||||||
|
b.WriteString(renderParamTable(requiredParams, true, true))
|
||||||
|
|
||||||
|
b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n")
|
||||||
|
b.WriteString(renderParamTable(defaultParams, false, false))
|
||||||
|
|
||||||
|
lifecycle := spec.Lifecycle
|
||||||
|
if lifecycle.DeleteModeDefault != "" || lifecycle.ResumeIfExistsDefault {
|
||||||
|
b.WriteString("\n<div class=\"lifecycle-note\">\n")
|
||||||
|
b.WriteString("<strong>Параметры жизненного цикла (Lifecycle defaults)</strong><br/>\n")
|
||||||
|
b.WriteString(fmt.Sprintf("По умолчанию: `delete_mode_default = %s`, `resume_if_exists_default = %v`.<br/>\n", lifecycle.DeleteModeDefault, lifecycle.ResumeIfExistsDefault))
|
||||||
|
b.WriteString("Если для сервиса доступны операции `suspend` и `resume`, то `delete` выполняет приостановку (без удаления данных),<br/>\n")
|
||||||
|
b.WriteString("а `apply` при необходимости возобновляет работу существующего ресурса.<br/>\n")
|
||||||
|
b.WriteString("Менять эти значения стоит только в продвинутых сценариях — через параметры `delete_mode` и `resume_if_exists` в манифесте.\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 {
|
||||||
|
b.WriteString(fmt.Sprintf("- `%s` (%s)\n", p.Code, p.Type))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
switch op.Action {
|
||||||
|
case "create":
|
||||||
|
b.WriteString(fmt.Sprintf("- `%s` — Создание кластера. Параметры: [%s params](%s_params_create.md)\n", name, "Create", spec.Name))
|
||||||
|
continue
|
||||||
|
case "modify":
|
||||||
|
b.WriteString(fmt.Sprintf("- `%s` — Изменение кластера. Параметры: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if op.Kind == "subresource" && op.Subresource != "" {
|
||||||
|
subFile := fmt.Sprintf("%s_%s.md", spec.Name, slug(op.Subresource))
|
||||||
|
if fileExists(filepath.Join(docsDir, subFile)) {
|
||||||
|
b.WriteString(fmt.Sprintf("- `%s` — %s. См. [nubes_%s_%s](%s)\n", name, strings.TrimPrefix(descr, ": "), spec.Name, slug(op.Subresource), subFile))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 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>", formatCode(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(pickText(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>", formatCode(p.Code)))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(p)))
|
||||||
|
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickText(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) string {
|
||||||
|
value := sampleValue(p)
|
||||||
|
comment := strings.TrimSpace(stripHTML(p.Man))
|
||||||
|
if comment == "" {
|
||||||
|
comment = strings.TrimSpace(stripHTML(p.Descr))
|
||||||
|
}
|
||||||
|
if comment != "" {
|
||||||
|
return fmt.Sprintf("%s%s = %s # %s\n", indent, p.Code, value, comment)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s = %s\n", indent, p.Code, 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, ">", ">")
|
||||||
|
if idx := strings.Index(cleaned, ">"); idx >= 0 {
|
||||||
|
cleaned = cleaned[:idx]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultCell(value interface{}) string {
|
||||||
|
if !hasDefault(value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "<code>" + escapeText(formatLiteral(value)) + "</code>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickText(p ParamSpec) string {
|
||||||
|
if p.Descr == "" && p.Man == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if p.Man == "" {
|
||||||
|
return p.Descr
|
||||||
|
}
|
||||||
|
if p.Descr == "" {
|
||||||
|
return p.Man
|
||||||
|
}
|
||||||
|
if len(p.Man) > len(p.Descr) {
|
||||||
|
return p.Man
|
||||||
|
}
|
||||||
|
return p.Descr
|
||||||
|
}
|
||||||
|
|
||||||
|
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/>", "<br/>")
|
||||||
|
text = strings.ReplaceAll(text, "<br />", "<br/>")
|
||||||
|
text = strings.ReplaceAll(text, "<br>", "<br/>")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
# Generator (placeholder)
|
||||||
|
|
||||||
|
Этот каталог будет содержать генератор:
|
||||||
|
- читает resources_yaml/*
|
||||||
|
- генерирует thin ресурсы в internal/resources_gen
|
||||||
|
- обновляет registry.go
|
||||||
@@ -0,0 +1,996 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Param struct {
|
||||||
|
ID int `yaml:"id"`
|
||||||
|
Code string `yaml:"code"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
// NOTE(name-to-uuid): ref_svc_id marks params that accept UI names, resolved in core.
|
||||||
|
RefSvcId int `yaml:"ref_svc_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputParam struct {
|
||||||
|
Code string `yaml:"code"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Sensitive bool `yaml:"sensitive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceYAML struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
ServiceID int `yaml:"service_id"`
|
||||||
|
Create struct {
|
||||||
|
Params []Param `yaml:"params"`
|
||||||
|
} `yaml:"create"`
|
||||||
|
Modify struct {
|
||||||
|
Params []Param `yaml:"params"`
|
||||||
|
} `yaml:"modify"`
|
||||||
|
Outputs struct {
|
||||||
|
Params []OutputParam `yaml:"params"`
|
||||||
|
} `yaml:"outputs"`
|
||||||
|
Lifecycle struct {
|
||||||
|
DeleteModeDefault string `yaml:"delete_mode_default"`
|
||||||
|
ResumeIfExistsDefault bool `yaml:"resume_if_exists_default"`
|
||||||
|
} `yaml:"lifecycle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenResource struct {
|
||||||
|
Name string
|
||||||
|
ServiceID int
|
||||||
|
CreateParams []Param
|
||||||
|
CreateFixedParams []FixedParam
|
||||||
|
ModifyParams []Param
|
||||||
|
SchemaParams []Param
|
||||||
|
OutputParams []OutputParam
|
||||||
|
DeleteMode string
|
||||||
|
ResumeIfExists bool
|
||||||
|
|
||||||
|
UsesBool bool
|
||||||
|
UsesInt64 bool
|
||||||
|
UsesString bool
|
||||||
|
HasDefaults bool
|
||||||
|
NeedsBoolDefault bool
|
||||||
|
NeedsInt64Default bool
|
||||||
|
NeedsStringDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
resourcesDir := filepath.Join(root, "resources_yaml")
|
||||||
|
extraParamsDir := filepath.Join(root, "extra-params")
|
||||||
|
outDir := filepath.Join(root, "internal", "resources_gen")
|
||||||
|
|
||||||
|
services, err := loadServices(resourcesDir, extraParamsDir)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, svc := range services {
|
||||||
|
if err := writeResource(outDir, svc); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeRegistry(outDir, services); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadServices(dir string, extraDir string) ([]GenResource, error) {
|
||||||
|
var services []GenResource
|
||||||
|
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 svc ServiceYAML
|
||||||
|
if err := yaml.Unmarshal(b, &svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
createParams, createFixed := splitCreateParams(svc.Name, svc.Create.Params)
|
||||||
|
modifyParams := svc.Modify.Params
|
||||||
|
modifyParams = normalizeModifyParams(createParams, modifyParams)
|
||||||
|
schemaParams, err := buildSchemaParams(extraDir, svc.ServiceID, createParams, modifyParams)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outputParams := mergeOutputParams(commonOutputParams(), normalizeOutputParams(svc.Outputs.Params))
|
||||||
|
gr := GenResource{
|
||||||
|
Name: svc.Name,
|
||||||
|
ServiceID: svc.ServiceID,
|
||||||
|
CreateParams: createParams,
|
||||||
|
CreateFixedParams: createFixed,
|
||||||
|
ModifyParams: modifyParams,
|
||||||
|
SchemaParams: schemaParams,
|
||||||
|
OutputParams: outputParams,
|
||||||
|
DeleteMode: svc.Lifecycle.DeleteModeDefault,
|
||||||
|
ResumeIfExists: svc.Lifecycle.ResumeIfExistsDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzeParams := func(p Param) {
|
||||||
|
switch strings.ToLower(p.Type) {
|
||||||
|
case "bool":
|
||||||
|
gr.UsesBool = true
|
||||||
|
if p.Default != "" && !p.Required {
|
||||||
|
gr.NeedsBoolDefault = true
|
||||||
|
gr.HasDefaults = true
|
||||||
|
}
|
||||||
|
case "int", "int64", "number":
|
||||||
|
gr.UsesInt64 = true
|
||||||
|
if p.Default != "" && !p.Required {
|
||||||
|
gr.NeedsInt64Default = true
|
||||||
|
gr.HasDefaults = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
gr.UsesString = true
|
||||||
|
if p.Default != "" && !p.Required {
|
||||||
|
gr.NeedsStringDefault = true
|
||||||
|
gr.HasDefaults = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range gr.SchemaParams {
|
||||||
|
analyzeParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema defaults apply only to create params
|
||||||
|
gr.NeedsBoolDefault = false
|
||||||
|
gr.NeedsInt64Default = false
|
||||||
|
gr.NeedsStringDefault = false
|
||||||
|
for _, p := range gr.SchemaParams {
|
||||||
|
if p.Default == "" || p.Required {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(p.Type) {
|
||||||
|
case "bool":
|
||||||
|
gr.NeedsBoolDefault = true
|
||||||
|
case "int", "int64", "number":
|
||||||
|
gr.NeedsInt64Default = true
|
||||||
|
default:
|
||||||
|
gr.NeedsStringDefault = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
services = append(services, gr)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if walkErr != nil {
|
||||||
|
return nil, walkErr
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResource(outDir string, svc GenResource) error {
|
||||||
|
fileName := fmt.Sprintf("%d_%s_resource.go", svc.ServiceID, svc.Name)
|
||||||
|
filePath := filepath.Join(outDir, fileName)
|
||||||
|
|
||||||
|
tpl, err := template.New("resource").Funcs(template.FuncMap{
|
||||||
|
"ToCamel": toCamel,
|
||||||
|
"ToSnake": toSnake,
|
||||||
|
"ParamType": paramType,
|
||||||
|
"ParamDefault": paramDefault,
|
||||||
|
"ParamDefaultExpr": paramDefaultExpr,
|
||||||
|
"ParamFormat": paramFormat,
|
||||||
|
"ParamParse": paramParse,
|
||||||
|
"ParamDescription": paramDescription,
|
||||||
|
"OutputType": outputParamType,
|
||||||
|
"OutputIsMap": outputParamIsMap,
|
||||||
|
"OutputIsList": outputParamIsList,
|
||||||
|
"OutputSensitive": outputParamSensitive,
|
||||||
|
"FixedParamExpr": fixedParamExpr,
|
||||||
|
"bt": func() string { return "`" },
|
||||||
|
}).Parse(resourceTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted, err := format.Source(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
formatted = buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filePath, formatted, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRegistry(outDir string, services []GenResource) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteString("package resources_gen\n\n")
|
||||||
|
buf.WriteString("import \"github.com/hashicorp/terraform-plugin-framework/resource\"\n\n")
|
||||||
|
buf.WriteString("// Code generated by tools/gen. DO NOT EDIT.\n")
|
||||||
|
buf.WriteString("func AllResources() []func() resource.Resource {\n")
|
||||||
|
buf.WriteString("\treturn []func() resource.Resource{\n")
|
||||||
|
for _, svc := range services {
|
||||||
|
buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", toCamel(svc.Name)))
|
||||||
|
}
|
||||||
|
buf.WriteString("\t}\n")
|
||||||
|
buf.WriteString("}\n")
|
||||||
|
|
||||||
|
formatted, err := format.Source(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
formatted = buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filepath.Join(outDir, "registry.go"), formatted, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCamel(s string) string {
|
||||||
|
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' })
|
||||||
|
for i, p := range parts {
|
||||||
|
if len(p) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||||
|
}
|
||||||
|
out := strings.Join(parts, "")
|
||||||
|
return ensureGoIdent(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSnake(s string) string {
|
||||||
|
var out []rune
|
||||||
|
for i, r := range s {
|
||||||
|
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||||
|
out = append(out, '_')
|
||||||
|
}
|
||||||
|
out = append(out, rune(strings.ToLower(string(r))[0]))
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramType(p Param) string {
|
||||||
|
switch strings.ToLower(p.Type) {
|
||||||
|
case "bool":
|
||||||
|
return "types.Bool"
|
||||||
|
case "int", "int64", "number":
|
||||||
|
return "types.Int64"
|
||||||
|
default:
|
||||||
|
return "types.String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramDefault(p Param) string {
|
||||||
|
return p.Default
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureGoIdent(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "R"
|
||||||
|
}
|
||||||
|
first := rune(s[0])
|
||||||
|
if unicode.IsLetter(first) || first == '_' {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "R" + s
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramDefaultExpr(p Param) string {
|
||||||
|
if p.Default == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch strings.ToLower(p.Type) {
|
||||||
|
case "bool":
|
||||||
|
val := strings.ToLower(p.Default)
|
||||||
|
return fmt.Sprintf("booldefault.StaticBool(%s)", val)
|
||||||
|
case "int", "int64", "number":
|
||||||
|
return fmt.Sprintf("int64default.StaticInt64(%s)", p.Default)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("stringdefault.StaticString(%q)", p.Default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputParamType(p OutputParam) string {
|
||||||
|
switch strings.ToLower(p.Type) {
|
||||||
|
case "map":
|
||||||
|
return "types.Map"
|
||||||
|
case "list":
|
||||||
|
return "types.List"
|
||||||
|
default:
|
||||||
|
return "types.String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputParamIsMap(p OutputParam) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(p.Type), "map")
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputParamIsList(p OutputParam) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(p.Type), "list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputParamSensitive(p OutputParam) bool {
|
||||||
|
return p.Sensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramFormat(p Param, varName string) string {
|
||||||
|
switch strings.ToLower(p.Type) {
|
||||||
|
case "bool":
|
||||||
|
return fmt.Sprintf("resources_core.FormatBool(%s)", varName)
|
||||||
|
case "int", "int64", "number":
|
||||||
|
return fmt.Sprintf("resources_core.FormatInt64(%s)", varName)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("resources_core.FormatString(%s)", varName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramDescription(p Param) string {
|
||||||
|
if p.RefSvcId <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// refSvcId marks name-based references that must be resolved to UUID in core.
|
||||||
|
return fmt.Sprintf("\"UI display_name for service_id=%d (mapped to UUID in core)\"", p.RefSvcId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceTemplate = `package resources_gen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"terraform-provider-nubes/internal/core"
|
||||||
|
"terraform-provider-nubes/internal/resources_core"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||||
|
{{- if .NeedsInt64Default }}
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
|
||||||
|
{{- end }}
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Code generated by tools/gen. DO NOT EDIT.
|
||||||
|
// Service: {{.Name}}
|
||||||
|
// Service ID: {{.ServiceID}}
|
||||||
|
|
||||||
|
var _ resource.Resource = &{{ToCamel .Name}}Resource{}
|
||||||
|
var _ resource.ResourceWithModifyPlan = &{{ToCamel .Name}}Resource{}
|
||||||
|
|
||||||
|
type {{ToCamel .Name}}Resource struct {
|
||||||
|
client *core.UniversalClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type {{ToCamel .Name}}Model struct {
|
||||||
|
ID types.String {{bt}}tfsdk:"id"{{bt}}
|
||||||
|
ResourceName types.String {{bt}}tfsdk:"resource_name"{{bt}}
|
||||||
|
{{- range .SchemaParams }}
|
||||||
|
{{ToCamel .Code}} {{ParamType .}} {{bt}}tfsdk:"{{ToSnake .Code}}"{{bt}}
|
||||||
|
{{- end }}
|
||||||
|
DeleteMode types.String {{bt}}tfsdk:"delete_mode"{{bt}}
|
||||||
|
ResumeIfExists types.Bool {{bt}}tfsdk:"resume_if_exists"{{bt}}
|
||||||
|
{{- range .OutputParams }}
|
||||||
|
{{ToCamel .Code}} {{OutputType .}} {{bt}}tfsdk:"{{ToSnake .Code}}"{{bt}}
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New{{ToCamel .Name}}Resource() resource.Resource {
|
||||||
|
return &{{ToCamel .Name}}Resource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_{{.Name}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
attrs := map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{Computed: true},
|
||||||
|
"resource_name": schema.StringAttribute{Required: true},
|
||||||
|
{{- range .SchemaParams }}
|
||||||
|
"{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{
|
||||||
|
{{if .Required}}Required: true,{{else}}Optional: true,{{end}}
|
||||||
|
{{if and (ParamDefault .) (not .Required)}}Computed: true,{{end}}
|
||||||
|
{{if and (ParamDefault .) (not .Required)}}Default: {{ParamDefaultExpr .}},{{end}}
|
||||||
|
{{- if ParamDescription . }}MarkdownDescription: {{ParamDescription .}},{{end}}
|
||||||
|
},
|
||||||
|
{{- end }}
|
||||||
|
"delete_mode": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: stringdefault.StaticString("{{if .DeleteMode}}{{.DeleteMode}}{{else}}state_only{{end}}"),
|
||||||
|
},
|
||||||
|
"resume_if_exists": schema.BoolAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: booldefault.StaticBool({{if .ResumeIfExists}}true{{else}}false{{end}}),
|
||||||
|
},
|
||||||
|
{{- range .OutputParams }}
|
||||||
|
"{{ToSnake .Code}}": schema.{{if OutputIsMap .}}Map{{else if OutputIsList .}}List{{else}}String{{end}}Attribute{
|
||||||
|
Computed: true,
|
||||||
|
{{- if or (OutputIsMap .) (OutputIsList .) }}ElementType: types.StringType,{{- end }}
|
||||||
|
{{- if OutputSensitive . }}Sensitive: true,{{- end }}
|
||||||
|
},
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Schema = schema.Schema{Attributes: attrs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
|
||||||
|
if r.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var config *{{ToCamel .Name}}Model
|
||||||
|
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var state *{{ToCamel .Name}}Model
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state != nil && !state.ID.IsNull() && !state.ID.IsUnknown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.ResourceName.IsNull() || config.ResourceName.IsUnknown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resumeIfExists := true
|
||||||
|
if !config.ResumeIfExists.IsNull() && !config.ResumeIfExists.IsUnknown() {
|
||||||
|
resumeIfExists = config.ResumeIfExists.ValueBool()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Diagnostics.Append(resources_core.PlanExistingResourceDiagnostics(ctx, r.client, {{.ServiceID}}, config.ResourceName.ValueString(), resumeIfExists)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var data {{ToCamel .Name}}Model
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceName := data.ResourceName.ValueString()
|
||||||
|
resp.Diagnostics.Append(resources_core.CreateExistingResourceDiagnostics(ctx, r.client, {{.ServiceID}}, resourceName, data.ResumeIfExists.ValueBool())...)
|
||||||
|
|
||||||
|
params := map[int]string{
|
||||||
|
{{- range .CreateFixedParams }}
|
||||||
|
{{.ID}}: {{FixedParamExpr .}},
|
||||||
|
{{- end }}
|
||||||
|
{{- range .CreateParams }}
|
||||||
|
{{.ID}}: {{ParamFormat . (printf "data.%s" (ToCamel .Code))}},
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := resources_core.CreateResource(ctx, r.client, {{.ServiceID}}, resourceName, data.ResumeIfExists.ValueBool(), params)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data.ID = types.StringValue(id)
|
||||||
|
if r.client != nil {
|
||||||
|
outputs, outDiags := resources_core.FetchInstanceOutputs(ctx, r.client, data.ID.ValueString())
|
||||||
|
resp.Diagnostics.Append(outDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- range .OutputParams }}
|
||||||
|
data.{{ToCamel .Code}} = outputs.{{ToCamel .Code}}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .SchemaParams }}
|
||||||
|
// Sync input params from state_params to avoid drift (ex: git_path).
|
||||||
|
paramsMap, paramsDiags := resources_core.StringMapFromTypesMap(ctx, outputs.StateParams)
|
||||||
|
resp.Diagnostics.Append(paramsDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Map refSvcId UUIDs back to display_name for stable state.
|
||||||
|
paramsMap, paramsDiags = resources_core.ResolveRefSvcParamDisplayNames(ctx, r.client, {{.ServiceID}}, paramsMap)
|
||||||
|
resp.Diagnostics.Append(paramsDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- range .SchemaParams }}
|
||||||
|
if val, ok := paramsMap["{{.Code}}"] ; ok {
|
||||||
|
data.{{ToCamel .Code}} = {{ParamParse .}}(val)
|
||||||
|
}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var data *{{ToCamel .Name}}Model
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.client != nil && !data.ID.IsNull() && !data.ID.IsUnknown() {
|
||||||
|
remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, data.ID.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outputs, outDiags := resources_core.FetchInstanceOutputs(ctx, r.client, data.ID.ValueString())
|
||||||
|
resp.Diagnostics.Append(outDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- range .OutputParams }}
|
||||||
|
data.{{ToCamel .Code}} = outputs.{{ToCamel .Code}}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .SchemaParams }}
|
||||||
|
// Sync input params from state_params to avoid drift (ex: git_path).
|
||||||
|
paramsMap, paramsDiags := resources_core.StringMapFromTypesMap(ctx, outputs.StateParams)
|
||||||
|
resp.Diagnostics.Append(paramsDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Map refSvcId UUIDs back to display_name for stable state.
|
||||||
|
paramsMap, paramsDiags = resources_core.ResolveRefSvcParamDisplayNames(ctx, r.client, {{.ServiceID}}, paramsMap)
|
||||||
|
resp.Diagnostics.Append(paramsDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- range .SchemaParams }}
|
||||||
|
if val, ok := paramsMap["{{.Code}}"] ; ok {
|
||||||
|
data.{{ToCamel .Code}} = {{ParamParse .}}(val)
|
||||||
|
}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, data)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
var plan {{ToCamel .Name}}Model
|
||||||
|
var state {{ToCamel .Name}}Model
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceID := state.ID
|
||||||
|
if instanceID.IsNull() || instanceID.IsUnknown() {
|
||||||
|
instanceID = plan.ID
|
||||||
|
}
|
||||||
|
if instanceID.IsNull() || instanceID.IsUnknown() {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", "отсутствует id инстанса для изменения")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[int]string{
|
||||||
|
{{- range .ModifyParams }}
|
||||||
|
{{.ID}}: {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}},
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resources_core.UpdateResource(ctx, r.client, instanceID.ValueString(), params); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = instanceID
|
||||||
|
if r.client != nil && !instanceID.IsNull() && !instanceID.IsUnknown() {
|
||||||
|
outputs, outDiags := resources_core.FetchInstanceOutputs(ctx, r.client, instanceID.ValueString())
|
||||||
|
resp.Diagnostics.Append(outDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- range .OutputParams }}
|
||||||
|
plan.{{ToCamel .Code}} = outputs.{{ToCamel .Code}}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .SchemaParams }}
|
||||||
|
// Sync input params from state_params to avoid drift (ex: git_path).
|
||||||
|
paramsMap, paramsDiags := resources_core.StringMapFromTypesMap(ctx, outputs.StateParams)
|
||||||
|
resp.Diagnostics.Append(paramsDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Map refSvcId UUIDs back to display_name for stable state.
|
||||||
|
paramsMap, paramsDiags = resources_core.ResolveRefSvcParamDisplayNames(ctx, r.client, {{.ServiceID}}, paramsMap)
|
||||||
|
resp.Diagnostics.Append(paramsDiags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
{{- range .SchemaParams }}
|
||||||
|
if val, ok := paramsMap["{{.Code}}"] ; ok {
|
||||||
|
plan.{{ToCamel .Code}} = {{ParamParse .}}(val)
|
||||||
|
}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state *{{ToCamel .Name}}Model
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state == nil || state.ID.IsNull() || state.ID.IsUnknown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resources_core.DeleteResource(ctx, r.client, state.ID.ValueString(), state.DeleteMode.ValueString()); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *{{ToCamel .Name}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
// format helpers live in resources_core/helpers.go
|
||||||
|
`
|
||||||
|
|
||||||
|
type FixedParam struct {
|
||||||
|
ID int
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCreateParams(serviceName string, params []Param) ([]Param, []FixedParam) {
|
||||||
|
if len(params) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isResourceRealmParam(p Param) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(p.Code), "resourceRealm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedParamExpr(p FixedParam) string {
|
||||||
|
return fmt.Sprintf("resources_core.FormatString(types.StringValue(%q))", p.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeParams(createParams []Param, modifyParams []Param) []Param {
|
||||||
|
if len(createParams) == 0 && len(modifyParams) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
byCode := make(map[string]Param)
|
||||||
|
order := make([]string, 0, len(createParams)+len(modifyParams))
|
||||||
|
|
||||||
|
for _, p := range createParams {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byCode[key] = p
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range modifyParams {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := byCode[key]; !exists {
|
||||||
|
p.Required = false
|
||||||
|
byCode[key] = p
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := make([]Param, 0, len(order))
|
||||||
|
seen := make(map[string]struct{}, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
merged = append(merged, byCode[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModifyParams(createParams []Param, modifyParams []Param) []Param {
|
||||||
|
if len(modifyParams) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
createByCode := make(map[string]Param)
|
||||||
|
for _, p := range createParams {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createByCode[key] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := make([]Param, 0, len(modifyParams))
|
||||||
|
for _, p := range modifyParams {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if base, ok := createByCode[key]; ok {
|
||||||
|
p.Type = base.Type
|
||||||
|
}
|
||||||
|
normalized = append(normalized, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== extra params support =====
|
||||||
|
|
||||||
|
type ExtraParamSpec struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSchemaParams(extraDir string, serviceID int, createParams []Param, modifyParams []Param) ([]Param, error) {
|
||||||
|
base := mergeParams(createParams, modifyParams)
|
||||||
|
extra, err := loadExtraParams(extraDir, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
return mergeExtraParams(base, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadExtraParams(extraDir string, serviceID int) ([]Param, error) {
|
||||||
|
if extraDir == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(extraDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var params []Param
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fileSvcID, err := parseServiceID(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fileSvcID != serviceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(extraDir, entry.Name())
|
||||||
|
fileParams, err := parseExtraParamsFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params = append(params, fileParams...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExtraParamsFile(path string) ([]Param, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raw []map[string]ExtraParamSpec
|
||||||
|
if err := yaml.Unmarshal(b, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid extra params yaml %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make([]Param, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
if len(item) != 1 {
|
||||||
|
return nil, fmt.Errorf("invalid extra params entry in %s: expected single key", path)
|
||||||
|
}
|
||||||
|
for code, spec := range item {
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("invalid extra params entry in %s: empty code", path)
|
||||||
|
}
|
||||||
|
paramType := normalizeExtraType(spec.Type)
|
||||||
|
params = append(params, Param{
|
||||||
|
Code: code,
|
||||||
|
Type: paramType,
|
||||||
|
Required: spec.Required,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExtraType(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "bool", "boolean":
|
||||||
|
return "bool"
|
||||||
|
case "int", "int64", "number":
|
||||||
|
return "int64"
|
||||||
|
default:
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeExtraParams(base []Param, extra []Param) ([]Param, error) {
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
byCode := make(map[string]struct{}, len(base))
|
||||||
|
for _, p := range base {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byCode[key] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range extra {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("invalid extra param: empty code")
|
||||||
|
}
|
||||||
|
if _, exists := byCode[key]; exists {
|
||||||
|
return nil, fmt.Errorf("extra param %q conflicts with existing API param", p.Code)
|
||||||
|
}
|
||||||
|
byCode[key] = struct{}{}
|
||||||
|
base = append(base, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOutputParams(params []OutputParam) []OutputParam {
|
||||||
|
normalized := make([]OutputParam, 0, len(params))
|
||||||
|
for _, p := range params {
|
||||||
|
code := strings.TrimSpace(p.Code)
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Code = code
|
||||||
|
p.Type = normalizeOutputType(p.Type)
|
||||||
|
normalized = append(normalized, p)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOutputType(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "map", "list", "string":
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonOutputParams() []OutputParam {
|
||||||
|
return []OutputParam{
|
||||||
|
{Code: "state_params", Type: "map"},
|
||||||
|
{Code: "state_out", Type: "map"},
|
||||||
|
{Code: "state_params_flat", Type: "map"},
|
||||||
|
{Code: "state_out_flat", Type: "map"},
|
||||||
|
{Code: "vault_secrets", Type: "map", Sensitive: true},
|
||||||
|
{Code: "vault_url", Type: "string"},
|
||||||
|
{Code: "vault_user_path", Type: "string"},
|
||||||
|
{Code: "vault_fields", Type: "list"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramParse(p Param) string {
|
||||||
|
switch strings.ToLower(p.Type) {
|
||||||
|
case "bool":
|
||||||
|
return "resources_core.ParseBool"
|
||||||
|
case "int", "int64", "number":
|
||||||
|
return "resources_core.ParseInt64"
|
||||||
|
default:
|
||||||
|
return "resources_core.ParseString"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeOutputParams(base []OutputParam, extra []OutputParam) []OutputParam {
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
byCode := make(map[string]struct{}, len(base))
|
||||||
|
for _, p := range base {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byCode[key] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range extra {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(p.Code))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := byCode[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byCode[key] = struct{}{}
|
||||||
|
base = append(base, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseServiceID(name string) (int, error) {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
var digits []rune
|
||||||
|
for _, r := range trimmed {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
digits = append(digits, r)
|
||||||
|
}
|
||||||
|
if len(digits) == 0 {
|
||||||
|
return 0, fmt.Errorf("extra params file %q must start with service id", name)
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(string(digits))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid service id in extra params file %q", name)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ops_docs_gen: builds per-service operations docs from resources_ops_yaml.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// - NUBES_OPS_YAML_DIR (default: <repo>/resources_ops_yaml)
|
||||||
|
// - NUBES_OPS_DOCS_DIR (default: <repo>/../docs/30_registry/resources/operations)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlDir := strings.TrimSpace(os.Getenv("NUBES_OPS_YAML_DIR"))
|
||||||
|
if yamlDir == "" {
|
||||||
|
yamlDir = filepath.Join(repoRoot, "resources_ops_yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
docsDir := strings.TrimSpace(os.Getenv("NUBES_OPS_DOCS_DIR"))
|
||||||
|
if docsDir == "" {
|
||||||
|
root := filepath.Dir(repoRoot)
|
||||||
|
docsDir = filepath.Join(root, "docs", "30_registry", "resources", "operations")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := []docEntry{}
|
||||||
|
|
||||||
|
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 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, sanitizeName(spec.Name))
|
||||||
|
outPath := filepath.Join(docsDir, fileName)
|
||||||
|
if err := writeServiceDoc(outPath, spec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, docEntry{
|
||||||
|
ServiceID: spec.ServiceID,
|
||||||
|
Name: spec.Name,
|
||||||
|
File: fileName,
|
||||||
|
Title: serviceTitle(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 := writeIndex(filepath.Join(docsDir, "index.md"), entries); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Generated %d operations docs in %s\n", len(entries), docsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
type docEntry struct {
|
||||||
|
ServiceID int
|
||||||
|
Name string
|
||||||
|
File string
|
||||||
|
Title string
|
||||||
|
OpCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeServiceDoc(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 title := serviceTitle(spec); title != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("Service: %s\n\n", title))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(formatValue(p.Default)),
|
||||||
|
p.ID,
|
||||||
|
esc(formatRef(p.RefSvcId)),
|
||||||
|
esc(formatList(p.ValueList)),
|
||||||
|
esc(p.Func),
|
||||||
|
esc(p.Regex),
|
||||||
|
esc(formatValue(p.MinValue)),
|
||||||
|
esc(formatValue(p.MaxValue)),
|
||||||
|
p.IsSensitive,
|
||||||
|
esc(formatValue(p.DependsOn)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeIndex(path string, entries []docEntry) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeName(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
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceTitle(spec ServiceOpsSpec) string {
|
||||||
|
if strings.TrimSpace(spec.ServiceDisplayName) != "" {
|
||||||
|
return spec.ServiceDisplayName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.ServiceShortName) != "" {
|
||||||
|
return spec.ServiceShortName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatList(items []string) string {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(items, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRef(value *int) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", *value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatValue(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func esc(value string) string {
|
||||||
|
return strings.ReplaceAll(value, "|", "\\|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoRoot() (string, error) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
current := wd
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
candidate := filepath.Join(current, "resources_yaml")
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(current)
|
||||||
|
if parent == current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("failed to locate universal_rebuild repo root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== spec =====
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationSpec struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
ID int `yaml:"id"`
|
||||||
|
Man string `yaml:"man,omitempty"`
|
||||||
|
Params []ParamSpec `yaml:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// service_ops_gen: builds per-service ops YAML from /index.cfm?endpoint=/services/{svcId}
|
||||||
|
// and /index.cfm?endpoint=/serviceOperation/{svcOperationId}.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// - NUBES_API_TOKEN (preferred) or TOKEN_FILE
|
||||||
|
// - NUBES_API_ENDPOINT (default: https://deck-api.ngcloud.ru/api/v1/index.cfm)
|
||||||
|
// - NUBES_SERVICE_ID (optional for single service)
|
||||||
|
// - NUBES_SERVICE_NAME (optional override for single service)
|
||||||
|
// - NUBES_SERVICES_FILE (default: devops/config/services_list.txt)
|
||||||
|
// - NUBES_OUTPUT_DIR (default: <repo>/resources_ops_yaml)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &apiClient{endpoint: cfg.ApiEndpoint, token: cfg.ApiToken}
|
||||||
|
|
||||||
|
services := cfg.Services
|
||||||
|
if cfg.SingleServiceID > 0 {
|
||||||
|
services = []serviceRef{{ID: cfg.SingleServiceID, Name: cfg.SingleServiceName}}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := cfg.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = filepath.Join(repoRoot, "resources_ops_yaml")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, svc := range services {
|
||||||
|
if svc.ID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := client.getService(svc.ID)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(svc.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(info.ShortName)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(info.DisplayName)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("service_%d", svc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
opSpecs, err := client.collectOperations(info.Operations)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := ServiceOpsSpec{
|
||||||
|
Name: name,
|
||||||
|
ServiceID: svc.ID,
|
||||||
|
ServiceDisplayName: info.DisplayName,
|
||||||
|
ServiceShortName: info.ShortName,
|
||||||
|
ServiceMan: strings.TrimSpace(info.Man),
|
||||||
|
Operations: opSpecs,
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(outputDir, fmt.Sprintf("%d_%s.yaml", svc.ID, name))
|
||||||
|
buf, err := yaml.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outPath, buf, 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("written %s\n", outPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== config =====
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
ApiEndpoint string
|
||||||
|
ApiToken string
|
||||||
|
Services []serviceRef
|
||||||
|
SingleServiceID int
|
||||||
|
SingleServiceName string
|
||||||
|
OutputDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceRef struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (config, error) {
|
||||||
|
apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm")
|
||||||
|
apiToken, err := loadToken()
|
||||||
|
if err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
singleID := 0
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("NUBES_SERVICE_ID")); raw != "" {
|
||||||
|
val, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, fmt.Errorf("invalid NUBES_SERVICE_ID: %s", raw)
|
||||||
|
}
|
||||||
|
singleID = val
|
||||||
|
}
|
||||||
|
|
||||||
|
singleName := strings.TrimSpace(os.Getenv("NUBES_SERVICE_NAME"))
|
||||||
|
outputDir := strings.TrimSpace(os.Getenv("NUBES_OUTPUT_DIR"))
|
||||||
|
|
||||||
|
services := []serviceRef{}
|
||||||
|
if singleID == 0 {
|
||||||
|
listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE"))
|
||||||
|
if listPath == "" {
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt")
|
||||||
|
}
|
||||||
|
list, err := readServicesList(listPath)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
services = list
|
||||||
|
}
|
||||||
|
|
||||||
|
return config{
|
||||||
|
ApiEndpoint: apiEndpoint,
|
||||||
|
ApiToken: apiToken,
|
||||||
|
Services: services,
|
||||||
|
SingleServiceID: singleID,
|
||||||
|
SingleServiceName: singleName,
|
||||||
|
OutputDir: outputDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvDefault(key string, def string) string {
|
||||||
|
val := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadToken() (string, error) {
|
||||||
|
if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" {
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
if tf := strings.TrimSpace(os.Getenv("TOKEN_FILE")); tf != "" {
|
||||||
|
b, err := os.ReadFile(tf)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
latest, err := findLatestToken(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if latest == "" {
|
||||||
|
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(latest)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLatestToken(dir string) (string, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var latest string
|
||||||
|
var latestTime int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mt := info.ModTime().Unix()
|
||||||
|
if mt > latestTime {
|
||||||
|
latestTime = mt
|
||||||
|
latest = filepath.Join(dir, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readServicesList(path string) ([]serviceRef, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(b), "\n")
|
||||||
|
services := []serviceRef{}
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimSpace(strings.ReplaceAll(raw, "\r", ""))
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "#") {
|
||||||
|
line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0])
|
||||||
|
}
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid service id in %s: %s", path, parts[0])
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
name = parts[1]
|
||||||
|
}
|
||||||
|
services = append(services, serviceRef{ID: id, Name: name})
|
||||||
|
}
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoRoot() (string, error) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
current := wd
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
candidate := filepath.Join(current, "resources_yaml")
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(current)
|
||||||
|
if parent == current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return "", errors.New("failed to locate universal_rebuild repo root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== api client =====
|
||||||
|
|
||||||
|
type apiClient struct {
|
||||||
|
endpoint string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceResponse struct {
|
||||||
|
Service serviceInfo `json:"svc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceInfo struct {
|
||||||
|
ID int `json:"svcId"`
|
||||||
|
DisplayName string `json:"svc"`
|
||||||
|
ShortName string `json:"svcShort"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
Operations []operationInfo `json:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationInfo struct {
|
||||||
|
SvcOperationId int `json:"svcOperationId"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceOperationResponse struct {
|
||||||
|
ServiceOperation serviceOperationInfo `json:"svcOperation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceOperationInfo struct {
|
||||||
|
SvcOperationId int `json:"svcOperationId"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
CfsParams []cfsParam `json:"cfsParams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfsParam struct {
|
||||||
|
ID int `json:"svcOperationCfsParamId"`
|
||||||
|
Code string `json:"svcOperationCfsParam"`
|
||||||
|
DataType string `json:"dataType"`
|
||||||
|
ValueList []interface{} `json:"valueList"`
|
||||||
|
RefSvcId *int `json:"refSvcId"`
|
||||||
|
IsRequired bool `json:"isRequired"`
|
||||||
|
DefaultValue interface{} `json:"defaultValue"`
|
||||||
|
Func string `json:"func"`
|
||||||
|
Regex string `json:"regex"`
|
||||||
|
UniqueScope string `json:"uniqueScope"`
|
||||||
|
MaxLength *int `json:"maxlength"`
|
||||||
|
MinLength *int `json:"minlength"`
|
||||||
|
MaxValue interface{} `json:"maxvalue"`
|
||||||
|
MinValue interface{} `json:"minvalue"`
|
||||||
|
Descr string `json:"descr"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
Sort *int `json:"sort"`
|
||||||
|
DependsOnCfsParams interface{} `json:"dependsOnCfsParams"`
|
||||||
|
IsModifiable *bool `json:"isModifiable"`
|
||||||
|
IsSensitive bool `json:"isSensitive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getService(serviceID int) (serviceInfo, error) {
|
||||||
|
endpoint := fmt.Sprintf("/services/%d", serviceID)
|
||||||
|
var res serviceResponse
|
||||||
|
if err := c.getViaProxy(endpoint, &res); err != nil {
|
||||||
|
return serviceInfo{}, err
|
||||||
|
}
|
||||||
|
return res.Service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getServiceOperation(svcOperationId int) (serviceOperationInfo, error) {
|
||||||
|
endpoint := fmt.Sprintf("/serviceOperation/%d", svcOperationId)
|
||||||
|
var res serviceOperationResponse
|
||||||
|
if err := c.getViaProxy(endpoint, &res); err != nil {
|
||||||
|
return serviceOperationInfo{}, err
|
||||||
|
}
|
||||||
|
return res.ServiceOperation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getViaProxy(endpoint string, out interface{}) error {
|
||||||
|
req, err := http.NewRequest("GET", c.endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("endpoint", endpoint)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
return dec.Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) collectOperations(ops []operationInfo) ([]OperationSpec, error) {
|
||||||
|
result := make([]OperationSpec, 0, len(ops))
|
||||||
|
for _, op := range ops {
|
||||||
|
opName := strings.ToLower(strings.TrimSpace(op.Operation))
|
||||||
|
if opName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
opInfo, err := c.getServiceOperation(op.SvcOperationId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params := make([]ParamSpec, 0, len(opInfo.CfsParams))
|
||||||
|
for _, p := range opInfo.CfsParams {
|
||||||
|
params = append(params, ParamSpec{
|
||||||
|
ID: p.ID,
|
||||||
|
Code: p.Code,
|
||||||
|
DataType: strings.TrimSpace(p.DataType),
|
||||||
|
Required: p.IsRequired,
|
||||||
|
Default: normalizeDefault(p.DefaultValue),
|
||||||
|
ValueList: normalizeValueList(p.ValueList),
|
||||||
|
RefSvcId: p.RefSvcId,
|
||||||
|
Func: strings.TrimSpace(p.Func),
|
||||||
|
Regex: strings.TrimSpace(p.Regex),
|
||||||
|
UniqueScope: strings.TrimSpace(p.UniqueScope),
|
||||||
|
MaxLength: p.MaxLength,
|
||||||
|
MinLength: p.MinLength,
|
||||||
|
MaxValue: normalizeDefault(p.MaxValue),
|
||||||
|
MinValue: normalizeDefault(p.MinValue),
|
||||||
|
Descr: strings.TrimSpace(p.Descr),
|
||||||
|
Man: strings.TrimSpace(p.Man),
|
||||||
|
Sort: p.Sort,
|
||||||
|
DependsOn: p.DependsOnCfsParams,
|
||||||
|
IsModifiable: p.IsModifiable,
|
||||||
|
IsSensitive: p.IsSensitive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(params, func(i, j int) bool { return params[i].ID < params[j].ID })
|
||||||
|
result = append(result, OperationSpec{
|
||||||
|
Name: opName,
|
||||||
|
ID: opInfo.SvcOperationId,
|
||||||
|
Man: strings.TrimSpace(opInfo.Man),
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].Name == result[j].Name {
|
||||||
|
return result[i].ID < result[j].ID
|
||||||
|
}
|
||||||
|
return result[i].Name < result[j].Name
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeValueList(values []interface{}) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
out = append(out, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDefault(value interface{}) interface{} {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch t := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== spec =====
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationSpec struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
ID int `yaml:"id"`
|
||||||
|
Man string `yaml:"man,omitempty"`
|
||||||
|
Params []ParamSpec `yaml:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// service_params_gen: builds resource YAML from /index.cfm?endpoint=/services/{svcId}
|
||||||
|
// and /index.cfm?endpoint=/serviceOperation/{svcOperationId}.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// - NUBES_API_TOKEN (or read from test_universal/terraform.tfvars)
|
||||||
|
// - NUBES_API_ENDPOINT (default: https://deck-api.ngcloud.ru/api/v1/index.cfm)
|
||||||
|
// - NUBES_SERVICE_ID (required)
|
||||||
|
// - NUBES_SERVICE_NAME (optional override for YAML name)
|
||||||
|
// - NUBES_OUTPUT (optional output path)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
extraDir := filepath.Join(repoRoot, "extra-params")
|
||||||
|
if err := validateExtraParams(extraDir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "extra-params validation failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &apiClient{
|
||||||
|
endpoint: cfg.ApiEndpoint,
|
||||||
|
token: cfg.ApiToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
service, err := client.getService(cfg.ServiceID)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := cfg.ServiceName
|
||||||
|
if name == "" {
|
||||||
|
name = service.Name
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("service_%d", cfg.ServiceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ops, createMan, hasSuspend, err := client.collectOperations(service.Operations)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
suspendOnDestroyDefault := hasSuspend
|
||||||
|
|
||||||
|
spec := ResourceSpec{
|
||||||
|
Name: name,
|
||||||
|
ServiceID: cfg.ServiceID,
|
||||||
|
Create: Operation{Params: ops["create"]},
|
||||||
|
Modify: Operation{Params: ops["modify"]},
|
||||||
|
Outputs: OutputSection{Params: defaultOutputParams()},
|
||||||
|
Lifecycle: Lifecycle{SuspendOnDestroyDefault: suspendOnDestroyDefault, AdoptExistingOnCreateDefault: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.OutputPath != "" {
|
||||||
|
if err := os.WriteFile(cfg.OutputPath, out, 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("written %s\n", cfg.OutputPath)
|
||||||
|
if err := writeInstruction(cfg.InstructionPath, name, service.Man, createMan); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInstruction(path string, name string, serviceMan string, createMan string) error {
|
||||||
|
man := strings.TrimSpace(serviceMan)
|
||||||
|
if man == "" {
|
||||||
|
man = strings.TrimSpace(createMan)
|
||||||
|
}
|
||||||
|
if man == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("# Инструкция для %s\n\n%s\n", name, man)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("written %s\n", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== config =====
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
ApiEndpoint string
|
||||||
|
ApiToken string
|
||||||
|
ServiceID int
|
||||||
|
ServiceName string
|
||||||
|
OutputPath string
|
||||||
|
InstructionPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (config, error) {
|
||||||
|
apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm")
|
||||||
|
apiToken := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN"))
|
||||||
|
if apiToken == "" {
|
||||||
|
apiToken = strings.TrimSpace(readTokenFromTfvars("/home/naeel/terra/universal_rebuild/test_universal/terraform.tfvars"))
|
||||||
|
}
|
||||||
|
if apiToken == "" {
|
||||||
|
return config{}, errors.New("NUBES_API_TOKEN is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceIDStr := strings.TrimSpace(os.Getenv("NUBES_SERVICE_ID"))
|
||||||
|
if serviceIDStr == "" {
|
||||||
|
return config{}, errors.New("NUBES_SERVICE_ID is required")
|
||||||
|
}
|
||||||
|
serviceID, err := strconv.Atoi(serviceIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, fmt.Errorf("invalid NUBES_SERVICE_ID: %s", serviceIDStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(os.Getenv("NUBES_SERVICE_NAME"))
|
||||||
|
output := strings.TrimSpace(os.Getenv("NUBES_OUTPUT"))
|
||||||
|
if output == "" {
|
||||||
|
output = filepath.Join("/home/naeel/terra/universal_rebuild/resources_yaml", fmt.Sprintf("%s.yaml", nameOrDefault(name, serviceID)))
|
||||||
|
}
|
||||||
|
instructionOutput := strings.TrimSpace(os.Getenv("NUBES_INSTRUCTION_OUTPUT"))
|
||||||
|
if instructionOutput == "" {
|
||||||
|
instructionOutput = filepath.Join("/home/naeel/terra/docs/30_registry/resources", fmt.Sprintf("%s_instruction.md", nameOrDefault(name, serviceID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return config{
|
||||||
|
ApiEndpoint: apiEndpoint,
|
||||||
|
ApiToken: apiToken,
|
||||||
|
ServiceID: serviceID,
|
||||||
|
ServiceName: name,
|
||||||
|
OutputPath: output,
|
||||||
|
InstructionPath: instructionOutput,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameOrDefault(name string, serviceID int) string {
|
||||||
|
if strings.TrimSpace(name) != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("service_%d", serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvDefault(key, def string) string {
|
||||||
|
val := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTokenFromTfvars(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`(?m)^\s*api_token\s*=\s*"([^"]+)"`)
|
||||||
|
m := re.FindStringSubmatch(string(b))
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== api client =====
|
||||||
|
|
||||||
|
type apiClient struct {
|
||||||
|
endpoint string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceResponse struct {
|
||||||
|
Service serviceInfo `json:"svc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceInfo struct {
|
||||||
|
ID int `json:"svcId"`
|
||||||
|
Name string `json:"svcShort"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
Operations []operationInfo `json:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationInfo struct {
|
||||||
|
SvcOperationId int `json:"svcOperationId"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceOperationResponse struct {
|
||||||
|
ServiceOperation serviceOperationInfo `json:"svcOperation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceOperationInfo struct {
|
||||||
|
SvcOperationId int `json:"svcOperationId"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
CfsParams []cfsParam `json:"cfsParams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfsParam struct {
|
||||||
|
ID int `json:"svcOperationCfsParamId"`
|
||||||
|
Code string `json:"svcOperationCfsParam"`
|
||||||
|
DataType string `json:"dataType"`
|
||||||
|
IsRequired bool `json:"isRequired"`
|
||||||
|
DefaultValue interface{} `json:"defaultValue"`
|
||||||
|
// NOTE(name-to-uuid): refSvcId marks params referencing another service instance.
|
||||||
|
RefSvcId *int `json:"refSvcId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getService(serviceID int) (serviceInfo, error) {
|
||||||
|
endpoint := fmt.Sprintf("/services/%d", serviceID)
|
||||||
|
var res serviceResponse
|
||||||
|
if err := c.getViaProxy(endpoint, &res); err != nil {
|
||||||
|
return serviceInfo{}, err
|
||||||
|
}
|
||||||
|
return res.Service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getServiceOperation(svcOperationId int) (serviceOperationInfo, error) {
|
||||||
|
endpoint := fmt.Sprintf("/serviceOperation/%d", svcOperationId)
|
||||||
|
var res serviceOperationResponse
|
||||||
|
if err := c.getViaProxy(endpoint, &res); err != nil {
|
||||||
|
return serviceOperationInfo{}, err
|
||||||
|
}
|
||||||
|
return res.ServiceOperation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getViaProxy(endpoint string, out interface{}) error {
|
||||||
|
req, err := http.NewRequest("GET", c.endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("endpoint", endpoint)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
return dec.Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) collectOperations(ops []operationInfo) (map[string][]Param, string, bool, error) {
|
||||||
|
result := map[string][]Param{
|
||||||
|
"create": {},
|
||||||
|
"modify": {},
|
||||||
|
}
|
||||||
|
createMan := ""
|
||||||
|
hasSuspend := false
|
||||||
|
for _, op := range ops {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(op.Operation))
|
||||||
|
if name == "suspend" {
|
||||||
|
hasSuspend = true
|
||||||
|
}
|
||||||
|
if name != "create" && name != "modify" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
opInfo, err := c.getServiceOperation(op.SvcOperationId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
if name == "create" && createMan == "" {
|
||||||
|
createMan = strings.TrimSpace(opInfo.Man)
|
||||||
|
}
|
||||||
|
params := make([]Param, 0, len(opInfo.CfsParams))
|
||||||
|
for _, p := range opInfo.CfsParams {
|
||||||
|
// Name -> UUID mapping is driven by refSvcId metadata from API.
|
||||||
|
// We persist it in YAML so the Go generator can mark name-based inputs.
|
||||||
|
params = append(params, Param{
|
||||||
|
ID: p.ID,
|
||||||
|
Code: p.Code,
|
||||||
|
Type: mapType(p.DataType),
|
||||||
|
Required: p.IsRequired,
|
||||||
|
Default: formatDefault(p.DefaultValue),
|
||||||
|
RefSvcId: refSvcIdValue(p.RefSvcId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(params, func(i, j int) bool { return params[i].ID < params[j].ID })
|
||||||
|
result[name] = params
|
||||||
|
}
|
||||||
|
return result, createMan, hasSuspend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapType(dt string) string {
|
||||||
|
d := strings.ToLower(strings.TrimSpace(dt))
|
||||||
|
if strings.Contains(d, "bool") {
|
||||||
|
return "bool"
|
||||||
|
}
|
||||||
|
if strings.Contains(d, "int") {
|
||||||
|
return "int64"
|
||||||
|
}
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDefault(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case bool:
|
||||||
|
if t {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case float64:
|
||||||
|
if t == float64(int64(t)) {
|
||||||
|
return fmt.Sprintf("%d", int64(t))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
default:
|
||||||
|
b, _ := json.Marshal(t)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== spec =====
|
||||||
|
|
||||||
|
type ResourceSpec struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
ServiceID int `yaml:"service_id"`
|
||||||
|
Create Operation `yaml:"create"`
|
||||||
|
Modify Operation `yaml:"modify"`
|
||||||
|
Outputs OutputSection `yaml:"outputs"`
|
||||||
|
Lifecycle Lifecycle `yaml:"lifecycle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Operation struct {
|
||||||
|
Params []Param `yaml:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputSection struct {
|
||||||
|
Params []OutputParam `yaml:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Lifecycle struct {
|
||||||
|
SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"`
|
||||||
|
AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Param struct {
|
||||||
|
ID int `yaml:"id"`
|
||||||
|
Code string `yaml:"code"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default,omitempty"`
|
||||||
|
RefSvcId int `yaml:"ref_svc_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputParam struct {
|
||||||
|
Code string `yaml:"code"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Sensitive bool `yaml:"sensitive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func refSvcIdValue(value *int) int {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultOutputParams() []OutputParam {
|
||||||
|
return []OutputParam{
|
||||||
|
{Code: "state_params", Type: "map"},
|
||||||
|
{Code: "state_out", Type: "map"},
|
||||||
|
{Code: "state_params_flat", Type: "map"},
|
||||||
|
{Code: "state_out_flat", Type: "map"},
|
||||||
|
{Code: "vault_secrets", Type: "map", Sensitive: true},
|
||||||
|
{Code: "vault_url", Type: "string"},
|
||||||
|
{Code: "vault_user_path", Type: "string"},
|
||||||
|
{Code: "vault_fields", Type: "list"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== extra params validation =====
|
||||||
|
|
||||||
|
type ExtraParamSpec struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoRoot() (string, error) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
current := wd
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
candidate := filepath.Join(current, "resources_yaml")
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(current)
|
||||||
|
if parent == current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return "", errors.New("failed to locate universal_rebuild repo root")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExtraParams(extraDir string) error {
|
||||||
|
entries, err := os.ReadDir(extraDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := parseServiceID(entry.Name()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(extraDir, entry.Name())
|
||||||
|
if err := validateExtraParamsFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExtraParamsFile(path string) error {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var raw []map[string]ExtraParamSpec
|
||||||
|
if err := yaml.Unmarshal(b, &raw); err != nil {
|
||||||
|
return fmt.Errorf("invalid extra params yaml %s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, item := range raw {
|
||||||
|
if len(item) != 1 {
|
||||||
|
return fmt.Errorf("invalid extra params entry in %s: expected single key", path)
|
||||||
|
}
|
||||||
|
for code, spec := range item {
|
||||||
|
if strings.TrimSpace(code) == "" {
|
||||||
|
return fmt.Errorf("invalid extra params entry in %s: empty code", path)
|
||||||
|
}
|
||||||
|
_ = normalizeExtraType(spec.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExtraType(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "bool", "boolean":
|
||||||
|
return "bool"
|
||||||
|
case "int", "int64", "number":
|
||||||
|
return "int64"
|
||||||
|
default:
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseServiceID(name string) (int, error) {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
var digits []rune
|
||||||
|
for _, r := range trimmed {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
digits = append(digits, r)
|
||||||
|
}
|
||||||
|
if len(digits) == 0 {
|
||||||
|
return 0, fmt.Errorf("extra params file %q must start with service id", name)
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(string(digits))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid service id in extra params file %q", name)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// service_spec_gen: builds unified service YAML from API.
|
||||||
|
//
|
||||||
|
// Design goals:
|
||||||
|
// - One YAML per service, stored in resources_yaml.
|
||||||
|
// - The YAML includes all instance operations, subresource operations, and actions.
|
||||||
|
// - MAN and parameter metadata are embedded in the same YAML so docs and Go
|
||||||
|
// generators have a single source of truth.
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// - NUBES_API_TOKEN (preferred) or TOKEN_FILE
|
||||||
|
// - NUBES_API_ENDPOINT (default: https://deck-api.ngcloud.ru/api/v1/index.cfm)
|
||||||
|
// - NUBES_SERVICE_ID (optional for single service)
|
||||||
|
// - NUBES_SERVICE_NAME (optional override for single service)
|
||||||
|
// - NUBES_SERVICES_FILE (default: devops/config/services_list.txt)
|
||||||
|
// - NUBES_OUTPUT_DIR (default: <repo>/resources_yaml)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load env and services list configuration first so we can derive output paths.
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API client uses proxy endpoint with bearer token.
|
||||||
|
client := newAPIClient(cfg.ApiEndpoint, cfg.ApiToken)
|
||||||
|
|
||||||
|
// If a single service is requested, override the list from services_list.txt.
|
||||||
|
services := cfg.Services
|
||||||
|
if cfg.SingleServiceID > 0 {
|
||||||
|
services = []serviceRef{{ID: cfg.SingleServiceID, Name: cfg.SingleServiceName}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified YAML output directory. We never emit resources_ops_yaml anymore.
|
||||||
|
outputDir := cfg.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
// Resolve the repo root only if we need the default output dir.
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
outputDir = filepath.Join(repoRoot, "resources_yaml")
|
||||||
|
}
|
||||||
|
if err := validateOutputDir(outputDir); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, svc := range services {
|
||||||
|
if svc.ID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Fetch service metadata and the operation list.
|
||||||
|
info, err := client.getService(svc.ID)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// Prefer the explicit name from services_list.txt; otherwise derive from API.
|
||||||
|
name := strings.TrimSpace(svc.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(info.ShortName)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(info.DisplayName)
|
||||||
|
}
|
||||||
|
name = normalizeServiceName(name)
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("service_%d", svc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect operations and derive lifecycle defaults based on suspend/resume.
|
||||||
|
ops, hasSuspend, _, _, err := client.collectOperations(info.Operations)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspendOnDestroyDefault := hasSuspend
|
||||||
|
adoptExistingOnCreateDefault := false
|
||||||
|
|
||||||
|
// Build the unified service YAML spec. This is the only YAML for the service.
|
||||||
|
spec := ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
ServiceID: svc.ID,
|
||||||
|
ServiceDisplayName: strings.TrimSpace(info.DisplayName),
|
||||||
|
ServiceShortName: strings.TrimSpace(info.ShortName),
|
||||||
|
ServiceMan: strings.TrimSpace(info.Man),
|
||||||
|
Lifecycle: Lifecycle{
|
||||||
|
SuspendOnDestroyDefault: suspendOnDestroyDefault,
|
||||||
|
AdoptExistingOnCreateDefault: adoptExistingOnCreateDefault,
|
||||||
|
},
|
||||||
|
Outputs: OutputSection{Params: defaultOutputParams()},
|
||||||
|
Operations: ops,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write <service_id>_<name>.yaml to resources_yaml.
|
||||||
|
outPath := filepath.Join(outputDir, fmt.Sprintf("%d_%s.yaml", svc.ID, name))
|
||||||
|
buf, err := yaml.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outPath, buf, 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("written %s\n", outPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== config =====
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
ApiEndpoint string
|
||||||
|
ApiToken string
|
||||||
|
Services []serviceRef
|
||||||
|
SingleServiceID int
|
||||||
|
SingleServiceName string
|
||||||
|
OutputDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceRef struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (config, error) {
|
||||||
|
// API endpoint defaults to production; token is required.
|
||||||
|
apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://deck-api.ngcloud.ru/api/v1/index.cfm")
|
||||||
|
apiEndpoint = normalizeAPIEndpoint(apiEndpoint)
|
||||||
|
apiToken, err := loadToken()
|
||||||
|
if err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional single-service mode for debugging and local inspection.
|
||||||
|
singleID := 0
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("NUBES_SERVICE_ID")); raw != "" {
|
||||||
|
val, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, fmt.Errorf("invalid NUBES_SERVICE_ID: %s", raw)
|
||||||
|
}
|
||||||
|
singleID = val
|
||||||
|
}
|
||||||
|
|
||||||
|
singleName := strings.TrimSpace(os.Getenv("NUBES_SERVICE_NAME"))
|
||||||
|
outputDir := strings.TrimSpace(os.Getenv("NUBES_OUTPUT_DIR"))
|
||||||
|
|
||||||
|
services := []serviceRef{}
|
||||||
|
if singleID == 0 {
|
||||||
|
// services_list.txt is the source of truth for which services to generate.
|
||||||
|
listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE"))
|
||||||
|
if listPath == "" {
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt")
|
||||||
|
}
|
||||||
|
list, err := readServicesList(listPath)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
services = list
|
||||||
|
}
|
||||||
|
|
||||||
|
return config{
|
||||||
|
ApiEndpoint: apiEndpoint,
|
||||||
|
ApiToken: apiToken,
|
||||||
|
Services: services,
|
||||||
|
SingleServiceID: singleID,
|
||||||
|
SingleServiceName: singleName,
|
||||||
|
OutputDir: outputDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvDefault(key string, def string) string {
|
||||||
|
val := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadToken() (string, error) {
|
||||||
|
// Prefer explicit env token, then TOKEN_FILE, then the latest *.token.
|
||||||
|
if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" {
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
if tf := strings.TrimSpace(os.Getenv("TOKEN_FILE")); tf != "" {
|
||||||
|
b, err := os.ReadFile(tf)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
latest, err := findLatestToken(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if latest == "" {
|
||||||
|
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(latest)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLatestToken(dir string) (string, error) {
|
||||||
|
// Pick the newest *.token file by mtime.
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var latest string
|
||||||
|
var latestTime int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mt := info.ModTime().Unix()
|
||||||
|
if mt > latestTime {
|
||||||
|
latestTime = mt
|
||||||
|
latest = filepath.Join(dir, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readServicesList(path string) ([]serviceRef, error) {
|
||||||
|
// Each non-empty line is: <service_id> <name> [# comment]
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(b), "\n")
|
||||||
|
services := []serviceRef{}
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimSpace(strings.ReplaceAll(raw, "\r", ""))
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "#") {
|
||||||
|
line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0])
|
||||||
|
}
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid service id in %s: %s", path, parts[0])
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
name = parts[1]
|
||||||
|
}
|
||||||
|
services = append(services, serviceRef{ID: id, Name: name})
|
||||||
|
}
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoRoot() (string, error) {
|
||||||
|
// We locate the repo by finding the universal_rebuild directory.
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
current := wd
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
candidate := filepath.Join(current, "universal_rebuild")
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(current)
|
||||||
|
if parent == current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return "", errors.New("failed to locate universal_rebuild repo root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== api client =====
|
||||||
|
|
||||||
|
type apiClient struct {
|
||||||
|
endpoint string
|
||||||
|
token string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceResponse struct {
|
||||||
|
Service serviceInfo `json:"svc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceInfo struct {
|
||||||
|
ID int `json:"svcId"`
|
||||||
|
DisplayName string `json:"svc"`
|
||||||
|
ShortName string `json:"svcShort"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
Operations []operationInfo `json:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationInfo struct {
|
||||||
|
SvcOperationId int `json:"svcOperationId"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceOperationResponse struct {
|
||||||
|
ServiceOperation serviceOperationInfo `json:"svcOperation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceOperationInfo struct {
|
||||||
|
SvcOperationId int `json:"svcOperationId"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
CfsParams []cfsParam `json:"cfsParams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfsParam struct {
|
||||||
|
ID int `json:"svcOperationCfsParamId"`
|
||||||
|
Code string `json:"svcOperationCfsParam"`
|
||||||
|
DataType string `json:"dataType"`
|
||||||
|
ValueList []interface{} `json:"valueList"`
|
||||||
|
RefSvcId *int `json:"refSvcId"`
|
||||||
|
IsRequired bool `json:"isRequired"`
|
||||||
|
DefaultValue interface{} `json:"defaultValue"`
|
||||||
|
Func string `json:"func"`
|
||||||
|
Regex string `json:"regex"`
|
||||||
|
UniqueScope string `json:"uniqueScope"`
|
||||||
|
MaxLength *int `json:"maxlength"`
|
||||||
|
MinLength *int `json:"minlength"`
|
||||||
|
MaxValue interface{} `json:"maxvalue"`
|
||||||
|
MinValue interface{} `json:"minvalue"`
|
||||||
|
Descr string `json:"descr"`
|
||||||
|
Man string `json:"man"`
|
||||||
|
Sort *int `json:"sort"`
|
||||||
|
DependsOnCfsParams interface{} `json:"dependsOnCfsParams"`
|
||||||
|
IsModifiable *bool `json:"isModifiable"`
|
||||||
|
IsSensitive bool `json:"isSensitive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getService(serviceID int) (serviceInfo, error) {
|
||||||
|
// /services/{id} returns display names, MAN, and a list of operations.
|
||||||
|
endpoint := fmt.Sprintf("/services/%d", serviceID)
|
||||||
|
var res serviceResponse
|
||||||
|
if err := c.getViaProxy(endpoint, &res); err != nil {
|
||||||
|
return serviceInfo{}, err
|
||||||
|
}
|
||||||
|
return res.Service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getServiceOperation(svcOperationId int) (serviceOperationInfo, error) {
|
||||||
|
// /serviceOperation/{id} returns params and MAN for a single operation.
|
||||||
|
endpoint := fmt.Sprintf("/serviceOperation/%d", svcOperationId)
|
||||||
|
var res serviceOperationResponse
|
||||||
|
if err := c.getViaProxy(endpoint, &res); err != nil {
|
||||||
|
return serviceOperationInfo{}, err
|
||||||
|
}
|
||||||
|
return res.ServiceOperation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) getViaProxy(endpoint string, out interface{}) error {
|
||||||
|
// The API uses a proxy query parameter called "endpoint".
|
||||||
|
req, err := http.NewRequest("GET", c.endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("endpoint", endpoint)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
return dec.Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiClient) collectOperations(ops []operationInfo) ([]OperationSpec, bool, bool, bool, error) {
|
||||||
|
// Resolve each operation in detail and classify it into instance/subresource/action.
|
||||||
|
result := make([]OperationSpec, 0, len(ops))
|
||||||
|
hasSuspend := false
|
||||||
|
hasResume := false
|
||||||
|
hasDelete := false
|
||||||
|
for _, op := range ops {
|
||||||
|
opName := normalizeOperationName(op.Operation)
|
||||||
|
if opName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
opInfo, err := c.getServiceOperation(op.SvcOperationId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, false, err
|
||||||
|
}
|
||||||
|
// Params are captured with full metadata, including MAN/descr/constraints.
|
||||||
|
params := make([]ParamSpec, 0, len(opInfo.CfsParams))
|
||||||
|
for _, p := range opInfo.CfsParams {
|
||||||
|
params = append(params, ParamSpec{
|
||||||
|
ID: p.ID,
|
||||||
|
Code: p.Code,
|
||||||
|
DataType: strings.TrimSpace(p.DataType),
|
||||||
|
Required: p.IsRequired,
|
||||||
|
Default: normalizeDefault(p.DefaultValue),
|
||||||
|
ValueList: normalizeValueList(p.ValueList),
|
||||||
|
RefSvcId: p.RefSvcId,
|
||||||
|
Func: strings.TrimSpace(p.Func),
|
||||||
|
Regex: strings.TrimSpace(p.Regex),
|
||||||
|
UniqueScope: strings.TrimSpace(p.UniqueScope),
|
||||||
|
MaxLength: p.MaxLength,
|
||||||
|
MinLength: p.MinLength,
|
||||||
|
MaxValue: normalizeDefault(p.MaxValue),
|
||||||
|
MinValue: normalizeDefault(p.MinValue),
|
||||||
|
Descr: strings.TrimSpace(p.Descr),
|
||||||
|
Man: strings.TrimSpace(p.Man),
|
||||||
|
Sort: p.Sort,
|
||||||
|
DependsOn: p.DependsOnCfsParams,
|
||||||
|
IsModifiable: p.IsModifiable,
|
||||||
|
IsSensitive: p.IsSensitive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(params, func(i, j int) bool { return params[i].ID < params[j].ID })
|
||||||
|
|
||||||
|
// Operation name determines kind and action.
|
||||||
|
kind, action, subresource := classifyOperation(opName)
|
||||||
|
if kind == "instance" && action == "suspend" {
|
||||||
|
hasSuspend = true
|
||||||
|
}
|
||||||
|
if kind == "instance" && action == "resume" {
|
||||||
|
hasResume = true
|
||||||
|
}
|
||||||
|
if kind == "instance" && action == "delete" {
|
||||||
|
hasDelete = true
|
||||||
|
}
|
||||||
|
result = append(result, OperationSpec{
|
||||||
|
Name: opName,
|
||||||
|
ID: opInfo.SvcOperationId,
|
||||||
|
Kind: kind,
|
||||||
|
Action: action,
|
||||||
|
Subresource: subresource,
|
||||||
|
Man: strings.TrimSpace(opInfo.Man),
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].Name == result[j].Name {
|
||||||
|
return result[i].ID < result[j].ID
|
||||||
|
}
|
||||||
|
return result[i].Name < result[j].Name
|
||||||
|
})
|
||||||
|
return result, hasSuspend, hasResume, hasDelete, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyOperation(name string) (string, string, string) {
|
||||||
|
// Canonical classification rules:
|
||||||
|
// - create/modify/delete/suspend/resume -> instance
|
||||||
|
// - create_*/modify_*/delete_* -> subresource
|
||||||
|
// - everything else -> action
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if n == "create" || n == "modify" || n == "delete" || n == "suspend" || n == "resume" {
|
||||||
|
return "instance", n, ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(n, "create_") {
|
||||||
|
sub := strings.TrimPrefix(n, "create_")
|
||||||
|
if sub != "" {
|
||||||
|
return "subresource", "create", sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(n, "modify_") {
|
||||||
|
sub := strings.TrimPrefix(n, "modify_")
|
||||||
|
if sub != "" {
|
||||||
|
return "subresource", "modify", sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(n, "delete_") {
|
||||||
|
sub := strings.TrimPrefix(n, "delete_")
|
||||||
|
if sub != "" {
|
||||||
|
return "subresource", "delete", sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "action", n, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeValueList(values []interface{}) []string {
|
||||||
|
// YAML stores value_list as strings for stable docs rendering.
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
out = append(out, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDefault(value interface{}) interface{} {
|
||||||
|
// Keep numeric defaults as-is, but trim strings.
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch t := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== spec =====
|
||||||
|
|
||||||
|
type ServiceSpec 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"`
|
||||||
|
Lifecycle Lifecycle `yaml:"lifecycle"`
|
||||||
|
Outputs OutputSection `yaml:"outputs"`
|
||||||
|
Operations []OperationSpec `yaml:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Lifecycle struct {
|
||||||
|
SuspendOnDestroyDefault bool `yaml:"suspend_on_destroy_default"`
|
||||||
|
AdoptExistingOnCreateDefault bool `yaml:"adopt_existing_on_create_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputSection struct {
|
||||||
|
Params []OutputParam `yaml:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputParam struct {
|
||||||
|
Code string `yaml:"code"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Sensitive bool `yaml:"sensitive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationSpec struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
ID int `yaml:"id"`
|
||||||
|
Kind string `yaml:"kind"`
|
||||||
|
Action string `yaml:"action"`
|
||||||
|
Subresource string `yaml:"subresource,omitempty"`
|
||||||
|
Man string `yaml:"man,omitempty"`
|
||||||
|
Params []ParamSpec `yaml:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultOutputParams() []OutputParam {
|
||||||
|
// Standard outputs expected by the provider core and docs.
|
||||||
|
return []OutputParam{
|
||||||
|
{Code: "state_params", Type: "map"},
|
||||||
|
{Code: "state_out", Type: "map"},
|
||||||
|
{Code: "state_params_flat", Type: "map"},
|
||||||
|
{Code: "state_out_flat", Type: "map"},
|
||||||
|
{Code: "vault_secrets", Type: "map", Sensitive: true},
|
||||||
|
{Code: "vault_url", Type: "string"},
|
||||||
|
{Code: "vault_user_path", Type: "string"},
|
||||||
|
{Code: "vault_fields", Type: "list"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOutputDir(path string) error {
|
||||||
|
// Guardrail: legacy ops YAML is deprecated; unified YAML only lives in resources_yaml.
|
||||||
|
base := strings.ToLower(strings.TrimSpace(filepath.Base(path)))
|
||||||
|
if base == "resources_ops_yaml" {
|
||||||
|
return fmt.Errorf("invalid output dir %q: use resources_yaml for unified service specs", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAPIClient(endpoint string, token string) *apiClient {
|
||||||
|
// Short timeout prevents hanging forever on network or API stalls.
|
||||||
|
return &apiClient{
|
||||||
|
endpoint: endpoint,
|
||||||
|
token: token,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeServiceName(raw string) string {
|
||||||
|
// Service name must be filesystem-safe and ASCII for stable file naming.
|
||||||
|
return normalizeIdentifier(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOperationName(raw string) string {
|
||||||
|
// Normalize operation names into snake_case used by generators.
|
||||||
|
return normalizeIdentifier(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIdentifier(raw string) string {
|
||||||
|
// Convert to ASCII snake_case: letters/digits kept, everything else becomes underscore.
|
||||||
|
// CamelCase is split into words by inserting underscores before uppercase letters.
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var out []rune
|
||||||
|
lastUnderscore := false
|
||||||
|
prevLowerOrDigit := false
|
||||||
|
for _, r := range raw {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
if prevLowerOrDigit && !lastUnderscore {
|
||||||
|
out = append(out, '_')
|
||||||
|
}
|
||||||
|
out = append(out, r+'a'-'A')
|
||||||
|
lastUnderscore = false
|
||||||
|
prevLowerOrDigit = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r >= 'a' && r <= 'z' {
|
||||||
|
out = append(out, r)
|
||||||
|
lastUnderscore = false
|
||||||
|
prevLowerOrDigit = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
out = append(out, r)
|
||||||
|
lastUnderscore = false
|
||||||
|
prevLowerOrDigit = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Any non-ASCII or punctuation becomes a separator.
|
||||||
|
if !lastUnderscore && len(out) > 0 {
|
||||||
|
out = append(out, '_')
|
||||||
|
lastUnderscore = true
|
||||||
|
}
|
||||||
|
prevLowerOrDigit = false
|
||||||
|
}
|
||||||
|
result := strings.Trim(outToString(out), "_")
|
||||||
|
return collapseUnderscores(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outToString(runes []rune) string {
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collapseUnderscores(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
for strings.Contains(value, "__") {
|
||||||
|
value = strings.ReplaceAll(value, "__", "_")
|
||||||
|
}
|
||||||
|
return strings.Trim(value, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAPIEndpoint(raw string) string {
|
||||||
|
// Accept base API URL with or without index.cfm; normalize to index.cfm.
|
||||||
|
endpoint := strings.TrimSpace(raw)
|
||||||
|
if endpoint == "" {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(endpoint, "/index.cfm") {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
return strings.TrimRight(endpoint, "/") + "/index.cfm"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user