add: generator tools (sources)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user