diff --git a/universal_rebuild/tools/gen_v2/generate_resources_v2.go.bak b/universal_rebuild/tools/gen_v2/generate_resources_v2.go.bak deleted file mode 100644 index 7465552..0000000 --- a/universal_rebuild/tools/gen_v2/generate_resources_v2.go.bak +++ /dev/null @@ -1,2277 +0,0 @@ -// generate_resources_v2 — главный генератор Terraform-провайдера Nubes. -// -// ⛔ ПОДЧИНЯЕТСЯ devops/ARCHITECTURE.md — все правила генерации прописаны там. -// -// Пайплайн: YAML-спеки сервисов → Go-ресурсы + registry.go. -// -// На вход: resources_yaml/*.yaml (сгенерированы service_spec_gen из API). -// На выход: internal/resources_gen/*.go (CRUD-ресурсы, subresource'ы, registry). -// -// Три вида ресурсов: -// - instance → nubes_{service} (основной CRUD инстанса) -// - subresource → nubes_{service}_{sub} (пользователи, базы, ...) -// - action → ТОЛЬКО redeploy встроен как git_revision. -// restart/recovery/reconcile исключены (ARCHITECTURE.md). - -package main - -import ( - "bytes" - "fmt" - "go/format" // форматирование сгенерированного Go-кода - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - "text/template" // шаблоны для генерации кода - "unicode" - - "gopkg.in/yaml.v3" -) - -// ─── Входные структуры (из YAML) ─────────────────────────────────────────── - -// ParamSpec — один параметр операции как он описан в YAML. -// Соответствует cfsParam из API Nubes. -type ParamSpec struct { - ID int `yaml:"id"` // svcOperationCfsParamId - Code string `yaml:"code"` // имя параметра (camelCase) - DataType string `yaml:"data_type,omitempty"` // string | integer > 0 | boolean | map-fixed | uuid | json | ... - Required bool `yaml:"required"` // обязательный? - Default interface{} `yaml:"default,omitempty"` // значение по умолчанию (строка или число) - RefSvcId *int `yaml:"ref_svc_id,omitempty"` // ссылка на другой сервис (UUID-параметр) - Descr string `yaml:"descr,omitempty"` // описание - Man string `yaml:"man,omitempty"` // MAN-руководство - Sensitive bool `yaml:"is_sensitive,omitempty"` // секретный параметр? -} - -// OutputParam — выходной параметр сервиса (state_params, vault_secrets, ...). -type OutputParam struct { - Code string `yaml:"code"` // код: state_params, state_out, vault_url, ... - Type string `yaml:"type"` // map | string | list - Sensitive bool `yaml:"sensitive,omitempty"` // vault_secrets = true -} - -// OperationSpec — одна операция сервиса (create, modify, delete_user, ...). -type OperationSpec struct { - Name string `yaml:"name"` // имя операции (create, modify, ...) - ID int `yaml:"id"` // svcOperationId - Kind string `yaml:"kind"` // instance | subresource | action - Action string `yaml:"action"` // create | modify | delete | suspend | redeploy | ... - Subresource string `yaml:"subresource,omitempty"` // для subresource: user, database, topic, ... - Man string `yaml:"man,omitempty"` // MAN-руководство операции - Params []ParamSpec `yaml:"params"` // параметры операции -} - -// ServiceSpec — полная YAML-спецификация одного сервиса. -type ServiceSpec struct { - Name string `yaml:"name"` // snake_case имя (postgres, s3bucket, ...) - ServiceID int `yaml:"service_id"` // числовой ID сервиса в Nubes - Outputs struct { - Params []OutputParam `yaml:"params"` - } `yaml:"outputs"` - Lifecycle struct { - SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"` - AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"` - } `yaml:"lifecycle"` - Operations []OperationSpec `yaml:"operations"` -} - -type Param struct { - ID int - Code string - Type string - Required bool - Default string - RefSvcId int - Descr string - Man string - Sensitive bool - CreateOnly bool - ForceNew bool - // IsJson — атрибут является JSON-строкой (data_type: json в YAML-спеке). - // При IsJson=true в схему добавляется JsonNormalize план-модификатор, - // чтобы план и API-ответ (компактный JSON) всегда совпадали. - IsJson bool -} - -type GenResource struct { - Name string - ServiceID int - CreateParams []Param - ModifyParams []Param - SchemaParams []Param - CreateOnlyParams []Param - CreateOnlyRequiredParams []Param - OutputParams []OutputParam - HasRefSvcParams bool - SupportsSuspendDestroy bool - SuspendOnDestroy bool - AdoptExistingOnCreate bool - UsesBool bool - UsesInt64 bool - UsesString bool - HasDefaults bool - NeedsBoolDefault bool - NeedsInt64Default bool - NeedsStringDefault bool - HasDomainParam bool - DomainServiceIDs []int - // HasRedeploy: сервис поддерживает redeploy (пересборка из git). - // Если true — в основной ресурс добавляется поле git_revision. - // При изменении git_revision вызывается redeploy вместо modify. - // Правило из ARCHITECTURE.md: только redeploy включается как inline action. - // restart, recovery, reconcile — исключены (ручные операции, только UI). - HasRedeploy bool - // NeedsJsonPlanMod: хотя бы один атрибут имеет data_type: json. - // Управляет добавлением импорта planmodifier в сгенерированный файл. - NeedsJsonPlanMod bool - // NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update). - NeedsStringsImport bool -} - -type GenSubresource struct { - ServiceName string - ServiceID int - SubName string - CreateOpName string - ModifyOpName string - DeleteOpName string - CreateParams []Param - ModifyParams []Param - DeleteParams []Param - SchemaParams []Param - IdentityParams []Param - HasRefSvcParams bool - UsesBool bool - UsesInt64 bool - UsesString bool - HasDefaults bool - NeedsBoolDefault bool - NeedsInt64Default bool - NeedsStringDefault bool - NeedsBoolPlanMod bool - NeedsInt64PlanMod bool - NeedsStringPlanMod bool - // NeedsJsonPlanMod: хотя бы один атрибут имеет IsJson=true. - NeedsJsonPlanMod bool - // NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update). - NeedsStringsImport bool -} - -type GenAction struct { - ServiceName string - ServiceID int - ActionName string - OperationName string - Params []Param - SchemaParams []Param - UsesBool bool - UsesInt64 bool - UsesString bool - HasDefaults bool - NeedsBoolDefault bool - NeedsInt64Default bool - NeedsStringDefault bool - // NeedsJsonPlanMod: хотя бы один атрибут имеет IsJson=true. - NeedsJsonPlanMod bool - // NeedsStringsImport: есть строковые SchemaParams (нужен EqualFold). - NeedsStringsImport bool -} - -func main() { - root, err := os.Getwd() - if err != nil { - panic(err) - } - resourcesDir := strings.TrimSpace(os.Getenv("NUBES_RESOURCES_DIR")) - if resourcesDir == "" { - resourcesDir = filepath.Join(root, "resources_yaml") - } - outDir := strings.TrimSpace(os.Getenv("NUBES_RESOURCES_GEN_DIR")) - if outDir == "" { - outDir = filepath.Join(root, "internal", "resources_gen") - } - - instanceResources, subresources, actions, err := loadSpecs(resourcesDir) - if err != nil { - panic(err) - } - - for _, svc := range instanceResources { - if err := writeInstanceResource(outDir, svc); err != nil { - panic(err) - } - } - for _, sr := range subresources { - if err := writeSubresource(outDir, sr); err != nil { - panic(err) - } - } - for _, act := range actions { - if err := writeActionResource(outDir, act); err != nil { - panic(err) - } - } - if err := writeRegistry(outDir, instanceResources, subresources, actions); err != nil { - panic(err) - } -} - -func loadSpecs(dir string) ([]GenResource, []GenSubresource, []GenAction, error) { - var services []GenResource - var subs []GenSubresource - var actions []GenAction - domainServiceIDsSet := map[int]struct{}{} - 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 - } - - // P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях. - if err := validateSpec(path, &spec); err != nil { - return fmt.Errorf("%s: %w", path, err) - } - - createParams := []Param{} - modifyParams := []Param{} - supportsSuspendDestroy := false - for _, op := range spec.Operations { - if op.Kind != "instance" { - continue - } - switch op.Action { - case "create": - createParams = convertParams(op.Params) - case "modify": - modifyParams = convertParams(op.Params) - case "suspend": - supportsSuspendDestroy = true - } - } - schemaParams := mergeParams(createParams, modifyParams) - createParams = alignParamTypes(createParams, schemaParams) - modifyParams = alignParamTypes(modifyParams, schemaParams) - createOnly := computeCreateOnly(createParams, modifyParams) - schemaParams = markCreateOnly(schemaParams, createOnly) - hasDomainParam := false - for _, param := range schemaParams { - if strings.EqualFold(strings.TrimSpace(param.Code), "domain") { - hasDomainParam = true - domainServiceIDsSet[spec.ServiceID] = struct{}{} - break - } - } - adoptExistingOnCreate := false - - suspendOnDestroy := true - if spec.Lifecycle.SuspendOnDestroyDefault != nil { - suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault - } - - gr := GenResource{ - Name: spec.Name, - ServiceID: spec.ServiceID, - CreateParams: createParams, - ModifyParams: modifyParams, - SchemaParams: schemaParams, - CreateOnlyParams: filterCreateOnly(schemaParams), - CreateOnlyRequiredParams: filterCreateOnlyRequired(schemaParams), - OutputParams: normalizeOutputParams(spec.Outputs.Params), - HasRefSvcParams: hasRefSvcParams(schemaParams), - SupportsSuspendDestroy: supportsSuspendDestroy, - SuspendOnDestroy: suspendOnDestroy, - AdoptExistingOnCreate: adoptExistingOnCreate, - HasDomainParam: hasDomainParam, - } - analyzeParams(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) - gr.NeedsJsonPlanMod = analyzeJsonPlanMod(gr.SchemaParams) - // NeedsStringsImport = true если: - // 1) есть ref_svc params (RefSvcId > 0, != 12) — шаблон генерирует restore-casing блоки с strings.EqualFold - // 2) есть строковые ModifyParams — шаблон генерирует EqualFold в hasServiceParamChanges (Update) - // 3) есть строковые CreateOnlyParams — шаблон генерирует EqualFold в create-only validation (ModifyPlan) - // RefSvcId=12 (S3 Object Storage) НЕ генерирует restore-блок — для S3 UUID обязателен точный формат - gr.NeedsStringsImport = hasRestoreCasingParams(gr.SchemaParams) || analyzeNeedsStrings(gr.ModifyParams) || analyzeNeedsStrings(gr.CreateOnlyRequiredParams) || analyzeNeedsStrings(gr.CreateOnlyParams) - - // --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) --- - // Правила из ARCHITECTURE.md: - // - redeploy → встроить как git_revision в основной ресурс - // - restart, recovery, reconcile → исключить (ручные операции, только UI) - // - всё остальное → стандартный action-ресурс (на будущее, если появятся новые) - for _, op := range spec.Operations { - if op.Kind != "action" { - continue - } - switch op.Action { - case "redeploy": - gr.HasRedeploy = true - case "restart", "recovery", "reconcile": - // Исключены - default: - act := GenAction{ - ServiceName: spec.Name, - ServiceID: spec.ServiceID, - ActionName: op.Action, - OperationName: op.Name, - Params: convertParams(op.Params), - } - act.SchemaParams = act.Params - analyzeParams(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams) - act.NeedsJsonPlanMod = analyzeJsonPlanMod(act.SchemaParams) - act.NeedsStringsImport = analyzeNeedsStrings(act.SchemaParams) - actions = append(actions, act) - } - } - - services = append(services, gr) - - srByName := map[string]*GenSubresource{} - for _, op := range spec.Operations { - if op.Kind != "subresource" { - continue - } - name := strings.TrimSpace(op.Subresource) - if name == "" { - continue - } - sr := srByName[name] - if sr == nil { - sr = &GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name} - srByName[name] = sr - } - switch op.Action { - case "create": - sr.CreateOpName = op.Name - sr.CreateParams = convertParams(op.Params) - case "modify": - sr.ModifyOpName = op.Name - sr.ModifyParams = convertParams(op.Params) - case "delete": - sr.DeleteOpName = op.Name - sr.DeleteParams = convertParams(op.Params) - } - } - for _, sr := range srByName { - sr.SchemaParams = mergeParams(sr.CreateParams, sr.ModifyParams, sr.DeleteParams) - sr.CreateParams = alignParamTypes(sr.CreateParams, sr.SchemaParams) - sr.ModifyParams = alignParamTypes(sr.ModifyParams, sr.SchemaParams) - sr.DeleteParams = alignParamTypes(sr.DeleteParams, sr.SchemaParams) - createOnly := computeCreateOnly(sr.CreateParams, sr.ModifyParams) - sr.SchemaParams = markCreateOnly(sr.SchemaParams, createOnly) - forceNewCodes := buildSubresourceForceNewCodes(sr, createOnly) - sr.SchemaParams = markForceNew(sr.SchemaParams, forceNewCodes) - sr.IdentityParams = filterForceNew(sr.SchemaParams) - if len(sr.IdentityParams) == 0 { - sr.IdentityParams = sr.SchemaParams - } - sr.HasRefSvcParams = hasRefSvcParams(sr.SchemaParams) - analyzeParams(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams) - analyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams) - sr.NeedsJsonPlanMod = analyzeJsonPlanMod(sr.SchemaParams) - sr.NeedsStringsImport = analyzeNeedsStrings(sr.ModifyParams) - subs = append(subs, *sr) - } - - return nil - }) - - if walkErr != nil { - return nil, nil, nil, walkErr - } - - domainServiceIDs := make([]int, 0, len(domainServiceIDsSet)) - for serviceID := range domainServiceIDsSet { - domainServiceIDs = append(domainServiceIDs, serviceID) - } - sort.Ints(domainServiceIDs) - for idx := range services { - if len(domainServiceIDs) == 0 { - services[idx].DomainServiceIDs = nil - continue - } - services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...) - } - - sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) - sort.Slice(subs, func(i, j int) bool { - if subs[i].ServiceName == subs[j].ServiceName { - return subs[i].SubName < subs[j].SubName - } - return subs[i].ServiceName < subs[j].ServiceName - }) - sort.Slice(actions, func(i, j int) bool { - if actions[i].ServiceName == actions[j].ServiceName { - return actions[i].ActionName < actions[j].ActionName - } - return actions[i].ServiceName < actions[j].ServiceName - }) - - return services, subs, actions, nil -} - -func convertParams(params []ParamSpec) []Param { - out := make([]Param, 0, len(params)) - for _, p := range params { - typeName := normalizeParamType(p.DataType) - defVal := normalizeDefault(p.Default) - ref := 0 - if p.RefSvcId != nil { - ref = *p.RefSvcId - } - out = append(out, Param{ - ID: p.ID, - Code: p.Code, - Type: typeName, - Required: p.Required, - Default: defVal, - RefSvcId: ref, - Descr: strings.TrimSpace(p.Descr), - Man: strings.TrimSpace(p.Man), - Sensitive: p.Sensitive, - // IsJson: флаг для добавления JsonNormalize план-модификатора в схему. - IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"), - }) - } - return out -} - -func normalizeParamType(value string) string { - v := strings.ToLower(strings.TrimSpace(value)) - if strings.Contains(v, "bool") { - return "bool" - } - if strings.Contains(v, "int") || strings.Contains(v, "number") { - return "int64" - } - return "string" -} - -func normalizeDefault(value interface{}) string { - if value == nil { - return "" - } - switch t := value.(type) { - case string: - return strings.TrimSpace(t) - default: - return fmt.Sprintf("%v", t) - } -} - -func normalizeOutputParams(params []OutputParam) []OutputParam { - if len(params) == 0 { - return []OutputParam{} - } - return params -} - -func hasRefSvcParams(params []Param) bool { - for _, p := range params { - if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") { - return true - } - } - return false -} - -func mergeParams(sets ...[]Param) []Param { - seen := map[string]Param{} - for _, s := range sets { - for _, p := range s { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key == "" { - continue - } - if _, ok := seen[key]; !ok { - seen[key] = p - } - } - } - out := make([]Param, 0, len(seen)) - for _, p := range seen { - out = append(out, p) - } - sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code }) - return out -} - -func alignParamTypes(params []Param, schema []Param) []Param { - if len(params) == 0 || len(schema) == 0 { - return params - } - codeType := make(map[string]string, len(schema)) - for _, p := range schema { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key == "" { - continue - } - if strings.TrimSpace(p.Type) != "" { - codeType[key] = p.Type - } - } - if len(codeType) == 0 { - return params - } - - for i, p := range params { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key == "" { - continue - } - if t, ok := codeType[key]; ok && strings.TrimSpace(t) != "" { - params[i].Type = t - } - } - - return params -} - -func computeCreateOnly(createParams []Param, modifyParams []Param) map[string]struct{} { - modifyCodes := make(map[string]struct{}, len(modifyParams)) - for _, p := range modifyParams { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key == "" { - continue - } - modifyCodes[key] = struct{}{} - } - - createOnly := make(map[string]struct{}) - for _, p := range createParams { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key == "" { - continue - } - if _, ok := modifyCodes[key]; !ok { - createOnly[key] = struct{}{} - } - } - - return createOnly -} - -func markCreateOnly(schema []Param, createOnly map[string]struct{}) []Param { - if len(schema) == 0 || len(createOnly) == 0 { - return schema - } - - for i, p := range schema { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key == "" { - continue - } - if _, ok := createOnly[key]; ok { - schema[i].CreateOnly = true - } - } - - return schema -} - -func filterCreateOnly(schema []Param) []Param { - if len(schema) == 0 { - return []Param{} - } - - result := make([]Param, 0, len(schema)) - for _, p := range schema { - if p.CreateOnly { - result = append(result, p) - } - } - return result -} - -func filterCreateOnlyRequired(schema []Param) []Param { - if len(schema) == 0 { - return []Param{} - } - - result := make([]Param, 0, len(schema)) - for _, p := range schema { - if p.CreateOnly && p.Required { - result = append(result, p) - } - } - return result -} - -func analyzeParams(usesBool *bool, usesInt64 *bool, usesString *bool, hasDefaults *bool, needsBoolDefault *bool, needsInt64Default *bool, needsStringDefault *bool, params []Param) { - for _, p := range params { - switch strings.ToLower(p.Type) { - case "bool": - *usesBool = true - if p.Default != "" { - *needsBoolDefault = true - *hasDefaults = true - } - case "int", "int64", "number": - *usesInt64 = true - if p.Default != "" { - *needsInt64Default = true - *hasDefaults = true - } - default: - *usesString = true - if p.Default != "" { - *needsStringDefault = true - *hasDefaults = true - } - } - } -} - -func writeInstanceResource(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, - "ParamDefaultExpr": paramDefaultExpr, - "ParamFormat": paramFormat, - "ParamParse": paramParse, - "ParamDescription": paramDescription, - "OutputType": outputParamType, - "OutputIsMap": outputParamIsMap, - "OutputIsList": outputParamIsList, - "OutputSensitive": outputParamSensitive, - "bt": func() string { return "`" }, - }).Parse(instanceTemplate) - if err != nil { - return err - } - - var buf bytes.Buffer - if err := tpl.Execute(&buf, svc); err != nil { - return err - } - - formatted := formatSourceOrWarn(filePath, buf.Bytes()) - - return os.WriteFile(filePath, formatted, 0644) -} - -func writeSubresource(outDir string, sr GenSubresource) error { - name := fmt.Sprintf("%s_%s", sr.ServiceName, sr.SubName) - fileName := fmt.Sprintf("%d_%s_resource.go", sr.ServiceID, name) - filePath := filepath.Join(outDir, fileName) - - tpl, err := template.New("subresource").Funcs(template.FuncMap{ - "ToCamel": toCamel, - "ToSnake": toSnake, - "ParamType": paramType, - "ParamDefaultExpr": paramDefaultExpr, - "ParamFormat": paramFormat, - "ParamParse": paramParse, - "ParamDescription": paramDescription, - "PlanModifierType": planModifierType, - "PlanModifierExpr": planModifierExpr, - "bt": func() string { return "`" }, - }).Parse(subresourceTemplate) - if err != nil { - return err - } - - var buf bytes.Buffer - if err := tpl.Execute(&buf, sr); err != nil { - return err - } - - formatted := formatSourceOrWarn(filePath, buf.Bytes()) - - return os.WriteFile(filePath, formatted, 0644) -} - -func writeActionResource(outDir string, act GenAction) error { - name := fmt.Sprintf("%s_%s", act.ServiceName, act.ActionName) - fileName := fmt.Sprintf("%d_%s_action.go", act.ServiceID, name) - filePath := filepath.Join(outDir, fileName) - - tpl, err := template.New("action").Funcs(template.FuncMap{ - "ToCamel": toCamel, - "ToSnake": toSnake, - "ParamType": paramType, - "ParamDefaultExpr": paramDefaultExpr, - "ParamFormat": paramFormat, - "ParamParse": paramParse, - "ParamDescription": paramDescription, - "bt": func() string { return "`" }, - }).Parse(actionTemplate) - if err != nil { - return err - } - - var buf bytes.Buffer - if err := tpl.Execute(&buf, act); err != nil { - return err - } - - formatted := formatSourceOrWarn(filePath, buf.Bytes()) - - return os.WriteFile(filePath, formatted, 0644) -} - -func writeRegistry(outDir string, services []GenResource, subs []GenSubresource, actions []GenAction) 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_v2. 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))) - } - for _, sr := range subs { - buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", toCamel(sr.ServiceName+"_"+sr.SubName))) - } - for _, act := range actions { - buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", toCamel(act.ServiceName+"_"+act.ActionName))) - } - buf.WriteString("\t}\n") - buf.WriteString("}\n") - - regPath := filepath.Join(outDir, "registry.go") - formatted := formatSourceOrWarn(regPath, buf.Bytes()) - - return os.WriteFile(regPath, 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 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 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 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 paramDescription(p Param) string { - var desc string - if strings.TrimSpace(p.Descr) != "" { - desc = strings.TrimSpace(p.Descr) - } else if strings.TrimSpace(p.Man) != "" { - desc = strings.TrimSpace(p.Man) - } else if p.RefSvcId > 0 { - desc = fmt.Sprintf("UI display_name for service_id=%d (mapped to UUID in core)", p.RefSvcId) - } - if p.CreateOnly { - if desc == "" { - desc = "Не изменяется после создания." - } else { - desc = desc + " Не изменяется после создания." - } - } - if p.ForceNew { - if desc == "" { - desc = "Изменение требует пересоздания." - } else { - desc = desc + " Изменение требует пересоздания." - } - } - if desc == "" { - return "" - } - return fmt.Sprintf("%q", desc) -} - -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 ensureGoIdent(s string) string { - if s == "" { - return "R" - } - first := rune(s[0]) - if unicode.IsLetter(first) || first == '_' { - return s - } - return "R" + s -} - -const instanceTemplate = `package resources_gen - -import ( - "context" - {{- if .NeedsStringsImport }} - "strings" - {{- end }} - - "terraform-provider-nubes/internal/core" - "terraform-provider-nubes/internal/resources_core" - - "github.com/hashicorp/terraform-plugin-framework/path" - "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 }} - {{- if .NeedsStringDefault }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - {{- end }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// Code generated by tools/gen_v2. DO NOT EDIT. -// Service: {{.Name}} -// Service ID: {{.ServiceID}} - -var _ resource.Resource = &{{ToCamel .Name}}Resource{} -var _ resource.ResourceWithModifyPlan = &{{ToCamel .Name}}Resource{} -var _ resource.ResourceWithImportState = &{{ToCamel .Name}}Resource{} - -type {{ToCamel .Name}}Resource struct { - client *core.UniversalClient -} - -type {{ToCamel .Name}}Model struct { - ID types.String ` + "`" + `tfsdk:"id"` + "`" + ` - ResourceName types.String ` + "`" + `tfsdk:"resource_name"` + "`" + ` - OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + ` - LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + ` - {{- if .HasRedeploy }} - // --- Redeploy support (ARCHITECTURE.md) --- - // git_revision: при изменении вызывает redeploy вместо modify. - // Опциональное поле — если не задано, modify работает как обычно. - GitRevision types.String ` + "`" + `tfsdk:"git_revision"` + "`" + ` - {{- end }} - {{- range .SchemaParams }} - {{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` - {{- end }} - SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + ` - AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` - {{- range .OutputParams }} - {{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` - {{- 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, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, - "resource_name": schema.StringAttribute{Required: true}, - "operation_timeout": schema.StringAttribute{Optional: true}, - "log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."}, - {{- range .SchemaParams }} - "{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{ - {{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}} - {{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }} - {{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}} - {{- if .Sensitive }}Sensitive: true,{{end}} - {{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{- end }} - }, - {{- end }} - {{- if .HasRedeploy }} - // --- Redeploy support (ARCHITECTURE.md) --- - // При изменении git_revision вызывается redeploy вместо modify. - // Если не задано — modify работает без изменений. - "git_revision": schema.StringAttribute{Optional: true, MarkdownDescription: "Git revision (commit hash/tag). Changing this triggers redeploy instead of modify."}, - {{- end }} - "suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})}, - "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})}, - {{- range .OutputParams }} - {{- if or (OutputIsMap .) (OutputIsList .) }} - "{{ToSnake .Code}}": schema.{{if OutputIsMap .}}Map{{else}}List{{end}}Attribute{Computed: true, ElementType: types.StringType{{if OutputSensitive .}}, Sensitive: true{{end}}}, - {{- else }} - "{{ToSnake .Code}}": schema.StringAttribute{Computed: true{{if OutputSensitive .}}, Sensitive: true{{end}}}, - {{- 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 req.State.Raw.IsNull() && req.Plan.Raw.IsNull() { - return - } - - if state != nil && !state.ID.IsNull() && !state.ID.IsUnknown() { - var plan {{ToCamel .Name}}Model - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { - return - } - {{- if .HasRefSvcParams }} - // FIX(uuid-case): resolve ref_svc params НЕ делаем в ModifyPlan. - // Terraform правило: plan ОБЯЗАН равняться config для user-provided атрибутов. - // Resolve (displayName → UUID или uppercase → lowercase) нужен только для - // API-вызова в Create/Update. Делать его здесь = менять plan = ошибка - // "Provider produced invalid plan: planned value does not match config value". - {{- end }} - if !plan.ResourceName.IsNull() && !plan.ResourceName.IsUnknown() && !state.ResourceName.IsNull() && !state.ResourceName.IsUnknown() { - if plan.ResourceName.ValueString() != state.ResourceName.ValueString() { - resp.Diagnostics.AddError("Нельзя изменить resource_name", "Параметр resource_name задается при создании и не может быть изменен. Создайте новый ресурс с другим именем.") - return - } - } - {{- range .CreateOnlyParams }} - if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { - {{- if eq (ParamType .) "types.Bool" }} - if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() { - resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.") - return - } - {{- else if eq (ParamType .) "types.Int64" }} - if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() { - resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.") - return - } - {{- else }} - // FIX(uuid-case): сравниваем без учёта регистра — API возвращает UUID - // в lowercase, пользователь мог написать upper/mixed. Это одно и то же - // значение, менять его нельзя только если оно реально другое. - if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) { - resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.") - return - } - {{- end }} - } - {{- end }} - return - } - {{- range .SchemaParams }} - {{- if and .Required (eq (ParamDefaultExpr .) "") }} - if config.{{ToCamel .Code}}.IsNull() { - resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") - return - } - if config.{{ToCamel .Code}}.IsUnknown() { - return - } - {{- end }} - {{- end }} - - if config.ResourceName.IsNull() || config.ResourceName.IsUnknown() { - return - } - adoptExistingOnCreate := false - if !config.AdoptExistingOnCreate.IsNull() && !config.AdoptExistingOnCreate.IsUnknown() { - adoptExistingOnCreate = config.AdoptExistingOnCreate.ValueBool() - } - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} - if !config.{{ToCamel .Code}}.IsNull() && !config.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, config.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) - } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != config.{{ToCamel .Code}}.ValueString() { - config.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - {{- end }} - params := map[int]string{ - {{- range .CreateParams }} - {{.ID}}: {{ParamFormat . (printf "config.%s" (ToCamel .Code))}}, - {{- end }} - } - desiredDomain := "" - {{- if .HasDomainParam }} - if !config.Domain.IsNull() && !config.Domain.IsUnknown() { - desiredDomain = config.Domain.ValueString() - } - {{- end }} - domainServiceIDs := []int{ {{- range .DomainServiceIDs }}{{.}}, {{- end }} } - - resp.Diagnostics.Append(resources_core.PlanExistingResourceDiagnosticsWithParamsAndDomainAndServices(ctx, r.client, {{.ServiceID}}, config.ResourceName.ValueString(), adoptExistingOnCreate, params, desiredDomain, domainServiceIDs)...) -} - -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 - } - {{- range .SchemaParams }} - {{- if and .Required (eq (ParamDefaultExpr .) "") }} - if data.{{ToCamel .Code}}.IsNull() || data.{{ToCamel .Code}}.IsUnknown() { - resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") - return - } - {{- end }} - {{- end }} - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - // ══════════════════════════════════════════════════════════════════════════ - // ПРАВИЛО TERRAFORM (официальная документация): - // «If an attribute value is configured, it is NEVER valid to change that - // value in the plan.» — то есть plan ОБЯЗАН равняться config (= тому что - // написал пользователь). Менять plan запрещено на уровне фреймворка. - // - // ПРОБЛЕМА: API нашего облака возвращает UUID в нижнем регистре. - // Пользователь пишет: vapp_uid = "6214BA32-..." (верхний или смешанный). - // После apply API вернул: "6214ba32-..." → state != plan → Terraform кричит: - // «Provider produced inconsistent result after apply». - // - // РЕШЕНИЕ: корректировать STATE под PLAN, а не наоборот. - // Шаг 1 (здесь): сохраняем оригинальное значение из plan ДО того как - // ResolveRefSvcParamValue переведёт UUID в нижний регистр (нужен для API). - // Шаг 2 (ниже, после RefreshResourceState): восстанавливаем оригинальный - // регистр в state через strings.EqualFold (сравниваем без учёта регистра). - // ══════════════════════════════════════════════════════════════════════════ - original{{ToCamel .Code}} := data.{{ToCamel .Code}} - {{- end }} - {{- end }} - {{- end }} - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - if !data.{{ToCamel .Code}}.IsNull() && !data.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, data.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != data.{{ToCamel .Code}}.ValueString() { - data.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - {{- end }} - - resourceName := data.ResourceName.ValueString() - desiredDomain := "" - {{- if .HasDomainParam }} - if !data.Domain.IsNull() && !data.Domain.IsUnknown() { - desiredDomain = data.Domain.ValueString() - } - {{- end }} - domainServiceIDs := []int{ {{- range .DomainServiceIDs }}{{.}}, {{- end }} } - resp.Diagnostics.Append(resources_core.CreateExistingResourceDiagnosticsWithDomainAndServices(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), desiredDomain, domainServiceIDs)...) - - params := map[int]string{ - {{- range .CreateParams }} - {{.ID}}: {{ParamFormat . (printf "data.%s" (ToCamel .Code))}}, - {{- end }} - } - - operationTimeout := "" - if !data.OperationTimeout.IsNull() && !data.OperationTimeout.IsUnknown() { - operationTimeout = data.OperationTimeout.ValueString() - } - if !data.LogLevel.IsNull() && !data.LogLevel.IsUnknown() { - ctx = core.CtxWithLogLevel(ctx, data.LogLevel.ValueString()) - } - id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - data.ID = types.StringValue(id) - - state, diags := resources_core.RefreshResourceState(ctx, r.client, id, {{.ServiceID}}, data, []resources_core.StateField{ - {{- range .OutputParams }} - {Code: "{{.Code}}"}, - {{- end }} - }, []resources_core.InputField{ - {{- range .SchemaParams }} - {{- if or (le .RefSvcId 0) (ne .RefSvcId 12) }} - {Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"}, - {{- end }} - {{- end }} - }) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, state.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) - } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != state.{{ToCamel .Code}}.ValueString() { - state.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - // Restore user-provided casing в state (Create). - // EqualFold = «совпадают ли значения без учёта регистра?» - // Если да — значит API вернул «туже» строку, только в другом регистре. - // Заменяем state на original (то что было в plan/config пользователя). - // Итог: plan=="6214BA32-..." и state=="6214BA32-..." → нет diff → нет taint. - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - if !original{{ToCamel .Code}}.IsNull() && !original{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { - if strings.EqualFold(state.{{ToCamel .Code}}.ValueString(), original{{ToCamel .Code}}.ValueString()) { - state.{{ToCamel .Code}} = original{{ToCamel .Code}} - } - } - {{- end }} - {{- end }} - {{- end }} - - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) -} - -func (r *{{ToCamel .Name}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state {{ToCamel .Name}}Model - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - if state.ID.IsNull() || state.ID.IsUnknown() { - return - } - if r.client != nil { - remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, state.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if remove { - resp.State.RemoveResource(ctx) - return - } - } - - newState, diags := resources_core.RefreshResourceState(ctx, r.client, state.ID.ValueString(), {{.ServiceID}}, state, []resources_core.StateField{ - {{- range .OutputParams }} - {Code: "{{.Code}}"}, - {{- end }} - }, []resources_core.InputField{ - {{- range .SchemaParams }} - {{- if or (le .RefSvcId 0) (ne .RefSvcId 12) }} - {Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"}, - {{- end }} - {{- end }} - }) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - if !newState.{{ToCamel .Code}}.IsNull() && !newState.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, newState.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) - } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != newState.{{ToCamel .Code}}.ValueString() { - newState.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - // Restore user-provided casing в state (Read). - // При чтении у нас нет plan — но предыдущий state уже хранит значение - // в регистре пользователя (после первого Create оно было восстановлено). - // Берём prior state (переменная state) как эталон регистра. - // Если API вернул то же UUID только строчными буквами — восстанавливаем. - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() && !newState.{{ToCamel .Code}}.IsNull() && !newState.{{ToCamel .Code}}.IsUnknown() { - if strings.EqualFold(newState.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) { - newState.{{ToCamel .Code}} = state.{{ToCamel .Code}} - } - } - {{- end }} - {{- end }} - {{- end }} - - resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) -} - -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("Ошибка клиента", "отсутствует идентификатор экземпляра для modify") - return - } - - hasServiceParamChanges := false - {{- range .ModifyParams }} - if !hasServiceParamChanges { - if plan.{{ToCamel .Code}}.IsNull() != state.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() != state.{{ToCamel .Code}}.IsUnknown() { - hasServiceParamChanges = true - } else if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { - {{- if eq (ParamType .) "types.Bool" }} - if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() { - hasServiceParamChanges = true - } - {{- else if eq (ParamType .) "types.Int64" }} - if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() { - hasServiceParamChanges = true - } - {{- else }} - if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) { - hasServiceParamChanges = true - } - {{- end }} - } - } - {{- end }} - - {{- if .HasRedeploy }} - // --- Redeploy support (ARCHITECTURE.md) --- - // Проверяем изменился ли git_revision. - // Если да — вызываем redeploy вместо modify. - // git_revision = "" (не задано) → обычный modify. - redeployRequested := false - if !plan.GitRevision.IsNull() && !plan.GitRevision.IsUnknown() { - if state.GitRevision.IsNull() || state.GitRevision.IsUnknown() || plan.GitRevision.ValueString() != state.GitRevision.ValueString() { - redeployRequested = true - } - } - {{- end }} - - if !hasServiceParamChanges{{if .HasRedeploy}} && !redeployRequested{{end}} { - plan.ID = instanceID - {{- range .OutputParams }} - plan.{{ToCamel .Code}} = state.{{ToCamel .Code}} - {{- end }} - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - return - } - - operationTimeout := "" - if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { - operationTimeout = plan.OperationTimeout.ValueString() - } - if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { - ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) - } - - {{- if .HasRedeploy }} - // --- Redeploy support (ARCHITECTURE.md) --- - if redeployRequested { - // Вызываем redeploy — пустой params (операция не требует CFS-параметров) - if err := r.client.RunRedeployOperation(ctx, instanceID.ValueString(), operationTimeout); err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - } else { - {{- end }} - params := map[int]string{ - {{- range .ModifyParams }} - {{.ID}}: {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, - {{- end }} - } - if err := resources_core.UpdateResourceWithTimeout(ctx, r.client, instanceID.ValueString(), params, operationTimeout); err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - {{- if .HasRedeploy }} - } - {{- end }} - - plan.ID = instanceID - state, diags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{ - {{- range .OutputParams }} - {Code: "{{.Code}}"}, - {{- end }} - }, []resources_core.InputField{ - {{- range .SchemaParams }} - {{- if or (le .RefSvcId 0) (ne .RefSvcId 12) }} - {Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"}, - {{- end }} - {{- end }} - }) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, state.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddWarning("Failed to resolve {{ToSnake .Code}}", err.Error()) - } else if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != state.{{ToCamel .Code}}.ValueString() { - state.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - // Restore user-provided casing в state (Update). - // После API-вызова modify state содержит значения в lower-case от API. - // Plan == config == то что написал пользователь (регистр неизменён). - // EqualFold: если UUID совпадает без учёта регистра — берём из plan. - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (ne .RefSvcId 12) (eq (ParamType .) "types.String") }} - if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { - if strings.EqualFold(state.{{ToCamel .Code}}.ValueString(), plan.{{ToCamel .Code}}.ValueString()) { - state.{{ToCamel .Code}} = plan.{{ToCamel .Code}} - } - } - {{- end }} - {{- end }} - {{- end }} - - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) -} - -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.ID.IsNull() || state.ID.IsUnknown() { - return - } - - {{- if .SupportsSuspendDestroy }} - deleteMode := "state_only" - if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() { - deleteMode = "suspend" - } - {{- else }} - deleteMode := "delete" - {{- end }} - - operationTimeout := "" - if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() { - operationTimeout = state.OperationTimeout.ValueString() - } - if !state.LogLevel.IsNull() && !state.LogLevel.IsUnknown() { - ctx = core.CtxWithLogLevel(ctx, state.LogLevel.ValueString()) - } - if err := resources_core.DeleteResourceWithTimeout(ctx, r.client, state.ID.ValueString(), deleteMode, operationTimeout); err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } -} - -func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -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("Error", "Invalid client type") - return - } - r.client = client -} -` - -const subresourceTemplate = `package resources_gen - -import ( - "context" - "strings" - - "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/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - {{- if .NeedsInt64Default }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" - {{- end }} - {{- if .NeedsStringDefault }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - {{- end }} - {{- if .NeedsBoolPlanMod }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" - {{- end }} - {{- if .NeedsInt64PlanMod }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" - {{- end }} - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// Code generated by tools/gen_v2. DO NOT EDIT. -// Service: {{.ServiceName}} -// Subresource: {{.SubName}} - -var _ resource.Resource = &{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource{} - -// {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource manages a subresource via operations. -type {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource struct { - client *core.UniversalClient -} - -type {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model struct { - ID types.String ` + "`" + `tfsdk:"id"` + "`" + ` - {{ToCamel .ServiceName}}ID types.String ` + "`" + `tfsdk:"{{ToSnake .ServiceName}}_id"` + "`" + ` - AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` - SkipMissingOnDelete types.Bool ` + "`" + `tfsdk:"skip_missing_on_delete"` + "`" + ` - OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + ` - LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + ` - {{- range .SchemaParams }} - {{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` - {{- end }} -} - -func New{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource() resource.Resource { - return &{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource{} -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_{{.ServiceName}}_{{.SubName}}" -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attrs := map[string]schema.Attribute{ - "id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}}, - "{{ToSnake .ServiceName}}_id": schema.StringAttribute{ - Required: true, - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, - }, - "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, - "skip_missing_on_delete": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, - "operation_timeout": schema.StringAttribute{Optional: true}, - "log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."}, - {{- range .SchemaParams }} - "{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{ - {{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}} - {{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }} - {{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}} - {{- if .Sensitive }}Sensitive: true,{{end}} - {{- if .ForceNew }}PlanModifiers: []planmodifier.{{PlanModifierType .}}{ {{PlanModifierExpr .}} },{{else if .IsJson}}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{end}} - }, - {{- end }} - } - resp.Schema = schema.Schema{Attributes: attrs} -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { - return - } - {{- range .SchemaParams }} - {{- if and .Required (eq (ParamDefaultExpr .) "") }} - if plan.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() { - resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") - return - } - {{- end }} - {{- end }} - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} - if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, plan.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != plan.{{ToCamel .Code}}.ValueString() { - plan.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - {{- end }} - - instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) - if instanceUID == "" { - resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра") - return - } - adoptExistingOnCreate := !plan.AdoptExistingOnCreate.IsNull() && !plan.AdoptExistingOnCreate.IsUnknown() && plan.AdoptExistingOnCreate.ValueBool() - - idParams := map[string]string{ - {{- range .IdentityParams }} - "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, - {{- end }} - } - identityCodes := []string{ - {{- range .IdentityParams }} - "{{.Code}}", - {{- end }} - } - listKey, idKey := resources_core.ResolveSubresourceStateKeys("{{.SubName}}", identityCodes) - targetValue := strings.TrimSpace(idParams[idKey]) - if targetValue == "" { - for _, code := range identityCodes { - candidate := strings.TrimSpace(idParams[code]) - if candidate == "" { - continue - } - targetValue = candidate - idKey = code - break - } - } - - if targetValue != "" { - found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if known && found { - if adoptExistingOnCreate { - plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) - resp.Diagnostics.AddWarning("Подресурс уже существует", "Объект уже есть, выполняется усыновление: "+targetValue) - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - return - } - resp.Diagnostics.AddError("Подресурс уже существует", "Найден объект с таким именем: "+targetValue) - return - } - } - - params := resources_core.CompactParams(map[string]string{ - {{- range .CreateParams }} - "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, - {{- end }} - }) - - operationTimeout := "" - if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { - operationTimeout = plan.OperationTimeout.ValueString() - } - if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { - ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) - } - createErr := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.CreateOpName}}", params, operationTimeout) - if createErr != nil && resources_core.IsSubresourceTransientDependencyError(createErr) { - createErr = resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.CreateOpName}}", params, operationTimeout) - } - if createErr != nil { - if targetValue != "" { - found, known, checkErr := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) - if checkErr != nil { - resp.Diagnostics.AddError("Ошибка клиента", checkErr.Error()) - return - } - if known && found { - plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) - resp.Diagnostics.AddWarning("Подресурс подтверждён в state_out", "Операция create вернула ошибку, но объект найден в state_out и принят в state: "+targetValue) - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - return - } - } - if adoptExistingOnCreate && resources_core.IsSubresourceAlreadyExistsError(createErr) { - if targetValue != "" { - found, known, checkErr := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) - if checkErr != nil { - resp.Diagnostics.AddError("Ошибка клиента", checkErr.Error()) - return - } - if known && found { - plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) - resp.Diagnostics.AddWarning("Подресурс уже существует", "Операция вернула duplicate/exist, объект подтверждён в state_out, выполняется усыновление") - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - return - } - } - resp.Diagnostics.AddError("Нарушена консистентность", "Операция вернула duplicate/exist, но объект не найден в state_out") - return - } - resp.Diagnostics.AddError("Ошибка клиента", createErr.Error()) - return - } - if targetValue != "" { - found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if known && !found { - resp.Diagnostics.AddError("Нарушена консистентность", "Операция create завершилась успешно, но объект не найден в state_out: "+targetValue) - return - } - } - - plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { - return - } - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} - if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, plan.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != plan.{{ToCamel .Code}}.ValueString() { - plan.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - {{- end }} - - if "{{.ModifyOpName}}" == "" { - var state {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - if plan.ID.IsNull() || plan.ID.IsUnknown() { - plan.ID = state.ID - } - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - return - } - - instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) - if instanceUID == "" { - resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра") - return - } - - params := resources_core.CompactParams(map[string]string{ - {{- range .ModifyParams }} - "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, - {{- end }} - }) - - idParams := map[string]string{ - {{- range .IdentityParams }} - "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, - {{- end }} - } - - operationTimeout := "" - if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { - operationTimeout = plan.OperationTimeout.ValueString() - } - if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { - ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) - } - if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.ModifyOpName}}", params, operationTimeout); err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - - plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state {{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Model - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - {{- if .HasRefSvcParams }} - {{- range .SchemaParams }} - {{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }} - if !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() { - resolved{{ToCamel .Code}}, err := r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, state.{{ToCamel .Code}}.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if resolved{{ToCamel .Code}} != "" && resolved{{ToCamel .Code}} != state.{{ToCamel .Code}}.ValueString() { - state.{{ToCamel .Code}} = types.StringValue(resolved{{ToCamel .Code}}) - } - } - {{- end }} - {{- end }} - {{- end }} - - if "{{.DeleteOpName}}" == "" { - return - } - - instanceUID := strings.TrimSpace(state.{{ToCamel .ServiceName}}ID.ValueString()) - if instanceUID == "" { - return - } - skipMissingOnDelete := !state.SkipMissingOnDelete.IsNull() && !state.SkipMissingOnDelete.IsUnknown() && state.SkipMissingOnDelete.ValueBool() - - idParams := map[string]string{ - {{- range .IdentityParams }} - "{{.Code}}": {{ParamFormat . (printf "state.%s" (ToCamel .Code))}}, - {{- end }} - } - identityCodes := []string{ - {{- range .IdentityParams }} - "{{.Code}}", - {{- end }} - } - listKey, idKey := resources_core.ResolveSubresourceStateKeys("{{.SubName}}", identityCodes) - targetValue := strings.TrimSpace(idParams[idKey]) - if targetValue == "" { - for _, code := range identityCodes { - candidate := strings.TrimSpace(idParams[code]) - if candidate == "" { - continue - } - targetValue = candidate - idKey = code - break - } - } - - if targetValue != "" { - found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if known && !found { - if skipMissingOnDelete { - resp.Diagnostics.AddWarning("Подресурс не найден", "Объект отсутствует и будет пропущен: "+targetValue) - return - } - resp.Diagnostics.AddError("Подресурс не найден", "Объект отсутствует: "+targetValue) - return - } - } - - params := resources_core.CompactParams(map[string]string{ - {{- range .DeleteParams }} - "{{.Code}}": {{ParamFormat . (printf "state.%s" (ToCamel .Code))}}, - {{- end }} - }) - - operationTimeout := "" - if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() { - operationTimeout = state.OperationTimeout.ValueString() - } - if !state.LogLevel.IsNull() && !state.LogLevel.IsUnknown() { - ctx = core.CtxWithLogLevel(ctx, state.LogLevel.ValueString()) - } - if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.DeleteOpName}}", params, operationTimeout); err != nil { - if resources_core.IsSubresourceMissingError(err) { - if targetValue != "" { - found, known, checkErr := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) - if checkErr != nil { - resp.Diagnostics.AddError("Ошибка клиента", checkErr.Error()) - return - } - if known && found { - resp.Diagnostics.AddError("Нарушена консистентность", "Операция delete вернула not found, но объект всё ещё присутствует в state_out: "+targetValue) - return - } - } - if skipMissingOnDelete { - resp.Diagnostics.AddWarning("Подресурс не найден", "Операция delete вернула not found, объект отсутствует в state_out, удаление пропущено") - return - } - } - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - - if targetValue != "" { - found, known, err := resources_core.FindSubresourceInStateOut(ctx, r.client, instanceUID, listKey, idKey, targetValue) - if err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - if known && found { - resp.Diagnostics.AddError("Нарушена консистентность", "Операция delete завершилась успешно, но объект всё ещё присутствует в state_out: "+targetValue) - return - } - } -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}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("Error", "Invalid client type") - return - } - r.client = client -} -` - -const actionTemplate = `package resources_gen - -import ( - "context" - "strings" - - "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 }} - {{- if .NeedsStringDefault }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - {{- end }} -{{- if .NeedsJsonPlanMod }} - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" -{{- end }} - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// Code generated by tools/gen_v2. DO NOT EDIT. -// Service: {{.ServiceName}} -// Action: {{.ActionName}} - -var _ resource.Resource = &{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource{} - -// {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource triggers a one-time action. -type {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource struct { - client *core.UniversalClient -} - -type {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model struct { - ID types.String ` + "`" + `tfsdk:"id"` + "`" + ` - {{ToCamel .ServiceName}}ID types.String ` + "`" + `tfsdk:"{{ToSnake .ServiceName}}_id"` + "`" + ` - RunID types.String ` + "`" + `tfsdk:"run_id"` + "`" + ` - AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + ` - OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + ` - LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + ` - {{- range .SchemaParams }} - {{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + ` - {{- end }} -} - -func New{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource() resource.Resource { - return &{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource{} -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_{{.ServiceName}}_{{.ActionName}}" -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - attrs := map[string]schema.Attribute{ - "id": schema.StringAttribute{Computed: true}, - "{{ToSnake .ServiceName}}_id": schema.StringAttribute{Required: true}, - "run_id": schema.StringAttribute{Required: true}, - "adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, - "operation_timeout": schema.StringAttribute{Optional: true}, - "log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."}, - {{- range .SchemaParams }} - "{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{ - {{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}} - {{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (gt .RefSvcId 0) }}Computed: true,{{- end }} - {{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}} - {{- if .Sensitive }}Sensitive: true,{{end}} - {{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{end}} - }, - {{- end }} - } - resp.Schema = schema.Schema{Attributes: attrs} -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { - return - } - {{- range .SchemaParams }} - {{- if and .Required (eq (ParamDefaultExpr .) "") }} - if plan.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() { - resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.") - return - } - {{- end }} - {{- end }} - - instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) - runID := strings.TrimSpace(plan.RunID.ValueString()) - if instanceUID == "" || runID == "" { - resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра или run_id") - return - } - - params := map[string]string{ - {{- range .SchemaParams }} - "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, - {{- end }} - } - - operationTimeout := "" - if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { - operationTimeout = plan.OperationTimeout.ValueString() - } - if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { - ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) - } - if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.OperationName}}", params, operationTimeout); err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - - plan.ID = types.StringValue(resources_core.BuildActionID(instanceUID, "{{.OperationName}}", runID)) - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan {{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Model - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { - return - } - - instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) - runID := strings.TrimSpace(plan.RunID.ValueString()) - if instanceUID == "" || runID == "" { - resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра или run_id") - return - } - - params := map[string]string{ - {{- range .SchemaParams }} - "{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}, - {{- end }} - } - - if err := resources_core.RunOperationByCode(ctx, r.client, instanceUID, "{{.OperationName}}", params); err != nil { - resp.Diagnostics.AddError("Ошибка клиента", err.Error()) - return - } - - plan.ID = types.StringValue(resources_core.BuildActionID(instanceUID, "{{.OperationName}}", runID)) - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - // No-op: removing the resource does not trigger a remote action. -} - -func (r *{{ToCamel (printf "%s_%s" .ServiceName .ActionName)}}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("Error", "Invalid client type") - return - } - r.client = client -} -` - -// knownKinds — допустимые значения Kind операций. -var knownKinds = map[string]bool{ - "instance": true, - "subresource": true, - "action": true, -} - -// validateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds. -// P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования. -func validateSpec(path string, spec *ServiceSpec) error { - if spec.Name == "" { - return fmt.Errorf("missing required field: name") - } - if spec.ServiceID <= 0 { - return fmt.Errorf("missing required field: service_id") - } - for i, op := range spec.Operations { - if op.Kind == "" { - return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name) - } - if !knownKinds[op.Kind] { - return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action)", i, op.Name, op.Kind) - } - if op.Action == "" { - return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind) - } - if op.Kind == "subresource" && op.Subresource == "" { - return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name) - } - } - return nil -} - -// formatSourceOrWarn форматирует Go-код. При ошибке пишет warning в stderr и возвращает неформатированный код. -func formatSourceOrWarn(filePath string, src []byte) []byte { - formatted, err := format.Source(src) - if err != nil { - fmt.Fprintf(os.Stderr, "WARNING: gofmt failed for %s: %v — writing unformatted code\n", filePath, err) - return src - } - return formatted -} - -func buildSubresourceForceNewCodes(sr *GenSubresource, createOnly map[string]struct{}) map[string]struct{} { - forceNew := map[string]struct{}{} - if sr.ModifyOpName == "" { - for _, p := range sr.SchemaParams { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key != "" { - forceNew[key] = struct{}{} - } - } - return forceNew - } - for key := range createOnly { - forceNew[key] = struct{}{} - } - for _, p := range sr.DeleteParams { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key != "" { - forceNew[key] = struct{}{} - } - } - return forceNew -} - -func markForceNew(params []Param, forceNew map[string]struct{}) []Param { - if len(params) == 0 || len(forceNew) == 0 { - return params - } - for i, p := range params { - key := strings.ToLower(strings.TrimSpace(p.Code)) - if key == "" { - continue - } - if _, ok := forceNew[key]; ok { - params[i].ForceNew = true - } - } - return params -} - -func filterForceNew(params []Param) []Param { - result := []Param{} - for _, p := range params { - if p.ForceNew { - result = append(result, p) - } - } - return result -} - -// analyzeJsonPlanMod возвращает true, если хотя бы один параметр является JSON-строкой. -// Используется для условного добавления импорта planmodifier в сгенерированные файлы. -func analyzeJsonPlanMod(params []Param) bool { - for _, p := range params { - if p.IsJson { - return true - } - } - return false -} - -// hasRestoreCasingParams возвращает true, если есть ref_svc params с RefSvcId > 0 && != 12 -// (restore-casing блок в Create/Read/Update использует strings.EqualFold для них). -func hasRestoreCasingParams(params []Param) bool { - for _, p := range params { - if p.RefSvcId > 0 && p.RefSvcId != 12 && strings.EqualFold(strings.TrimSpace(p.Type), "string") { - return true - } - } - return false -} - -// analyzeNeedsStrings возвращает true, если среди params есть хотя бы один строковый параметр. -// Используется для условного добавления импорта "strings" (EqualFold в Update). -func analyzeNeedsStrings(params []Param) bool { - for _, p := range params { - t := strings.ToLower(strings.TrimSpace(p.Type)) - if t != "bool" && t != "int" && t != "int64" && t != "number" { - return true - } - } - return false -} - -func analyzePlanModifiers(needsBool *bool, needsInt64 *bool, needsString *bool, params []Param) { - for _, p := range params { - if !p.ForceNew { - continue - } - switch strings.ToLower(strings.TrimSpace(p.Type)) { - case "bool": - *needsBool = true - case "int", "int64", "number": - *needsInt64 = true - default: - *needsString = true - } - } -} - -func planModifierType(p Param) string { - switch strings.ToLower(strings.TrimSpace(p.Type)) { - case "bool": - return "Bool" - case "int", "int64", "number": - return "Int64" - default: - return "String" - } -} - -func planModifierExpr(p Param) string { - switch strings.ToLower(strings.TrimSpace(p.Type)) { - case "bool": - return "boolplanmodifier.RequiresReplace()" - case "int", "int64", "number": - return "int64planmodifier.RequiresReplace()" - default: - return "stringplanmodifier.RequiresReplace()" - } -} diff --git a/universal_rebuild/tools/gen_v2/helpers.go b/universal_rebuild/tools/gen_v2/helpers/helpers.go similarity index 58% rename from universal_rebuild/tools/gen_v2/helpers.go rename to universal_rebuild/tools/gen_v2/helpers/helpers.go index 4d1ca4a..28d1991 100644 --- a/universal_rebuild/tools/gen_v2/helpers.go +++ b/universal_rebuild/tools/gen_v2/helpers/helpers.go @@ -1,5 +1,5 @@ -// helpers — конвертация имён, форматирование, plan-модификаторы. -package main +// Package helpers — конвертация имён, форматирование, plan-модификаторы. +package helpers import ( "fmt" @@ -7,9 +7,12 @@ import ( "os" "strings" "unicode" + + "terraform-provider-nubes/tools/gen_v2/types" ) -func toCamel(s string) string { +// ToCamel конвертирует snake_case → CamelCase. +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 { @@ -18,10 +21,11 @@ func toCamel(s string) string { parts[i] = strings.ToUpper(p[:1]) + p[1:] } out := strings.Join(parts, "") - return ensureGoIdent(out) + return EnsureGoIdent(out) } -func toSnake(s string) string { +// ToSnake конвертирует CamelCase → snake_case. +func ToSnake(s string) string { var out []rune for i, r := range s { if i > 0 && r >= 'A' && r <= 'Z' { @@ -32,7 +36,8 @@ func toSnake(s string) string { return string(out) } -func paramType(p Param) string { +// ParamType возвращает Go-тип для параметра (types.Bool/Int64/String). +func ParamType(p types.Param) string { switch strings.ToLower(p.Type) { case "bool": return "types.Bool" @@ -43,7 +48,8 @@ func paramType(p Param) string { } } -func paramDefaultExpr(p Param) string { +// ParamDefaultExpr возвращает выражение значения по умолчанию. +func ParamDefaultExpr(p types.Param) string { if p.Default == "" { return "" } @@ -58,7 +64,8 @@ func paramDefaultExpr(p Param) string { } } -func paramFormat(p Param, varName string) string { +// ParamFormat возвращает вызов Format* для форматирования значения параметра. +func ParamFormat(p types.Param, varName string) string { switch strings.ToLower(p.Type) { case "bool": return fmt.Sprintf("resources_core.FormatBool(%s)", varName) @@ -69,7 +76,8 @@ func paramFormat(p Param, varName string) string { } } -func paramParse(p Param) string { +// ParamParse возвращает имя Parse*-функции для параметра. +func ParamParse(p types.Param) string { switch strings.ToLower(p.Type) { case "bool": return "resources_core.ParseBool" @@ -80,7 +88,8 @@ func paramParse(p Param) string { } } -func paramDescription(p Param) string { +// ParamDescription формирует MarkdownDescription для параметра. +func ParamDescription(p types.Param) string { var desc string if strings.TrimSpace(p.Descr) != "" { desc = strings.TrimSpace(p.Descr) @@ -109,7 +118,8 @@ func paramDescription(p Param) string { return fmt.Sprintf("%q", desc) } -func outputParamType(p OutputParam) string { +// OutputParamType возвращает Go-тип для выходного параметра (types.Map/List/String). +func OutputParamType(p types.OutputParam) string { switch strings.ToLower(p.Type) { case "map": return "types.Map" @@ -120,19 +130,23 @@ func outputParamType(p OutputParam) string { } } -func outputParamIsMap(p OutputParam) bool { +// OutputParamIsMap возвращает true, если выходной параметр — map. +func OutputParamIsMap(p types.OutputParam) bool { return strings.EqualFold(strings.TrimSpace(p.Type), "map") } -func outputParamIsList(p OutputParam) bool { +// OutputParamIsList возвращает true, если выходной параметр — list. +func OutputParamIsList(p types.OutputParam) bool { return strings.EqualFold(strings.TrimSpace(p.Type), "list") } -func outputParamSensitive(p OutputParam) bool { +// OutputParamSensitive возвращает true, если параметр секретный. +func OutputParamSensitive(p types.OutputParam) bool { return p.Sensitive } -func ensureGoIdent(s string) string { +// EnsureGoIdent проверяет, что строка начинается с буквы или _. +func EnsureGoIdent(s string) string { if s == "" { return "R" } @@ -143,8 +157,8 @@ func ensureGoIdent(s string) string { return "R" + s } - -func formatSourceOrWarn(filePath string, src []byte) []byte { +// FormatSourceOrWarn форматирует Go-код. При ошибке пишет warning в stderr. +func FormatSourceOrWarn(filePath string, src []byte) []byte { formatted, err := format.Source(src) if err != nil { fmt.Fprintf(os.Stderr, "WARNING: gofmt failed for %s: %v — writing unformatted code\n", filePath, err) @@ -153,7 +167,8 @@ func formatSourceOrWarn(filePath string, src []byte) []byte { return formatted } -func planModifierType(p Param) string { +// PlanModifierType возвращает суффикс типа план-модификатора (Bool/Int64/String). +func PlanModifierType(p types.Param) string { switch strings.ToLower(strings.TrimSpace(p.Type)) { case "bool": return "Bool" @@ -164,7 +179,8 @@ func planModifierType(p Param) string { } } -func planModifierExpr(p Param) string { +// PlanModifierExpr возвращает выражение план-модификатора RequiresReplace. +func PlanModifierExpr(p types.Param) string { switch strings.ToLower(strings.TrimSpace(p.Type)) { case "bool": return "boolplanmodifier.RequiresReplace()" diff --git a/universal_rebuild/tools/gen_v2/loader.go b/universal_rebuild/tools/gen_v2/loader.go deleted file mode 100644 index b98296c..0000000 --- a/universal_rebuild/tools/gen_v2/loader.go +++ /dev/null @@ -1,282 +0,0 @@ -// loader — загрузка YAML-спеков и построение ресурсов. -package main - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - - "gopkg.in/yaml.v3" -) - -func loadSpecs(dir string) ([]GenResource, []GenSubresource, []GenAction, error) { - var services []GenResource - var subs []GenSubresource - var actions []GenAction - domainServiceIDsSet := map[int]struct{}{} - 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 - } - - // P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях. - if err := validateSpec(path, &spec); err != nil { - return fmt.Errorf("%s: %w", path, err) - } - - createParams := []Param{} - modifyParams := []Param{} - supportsSuspendDestroy := false - for _, op := range spec.Operations { - if op.Kind != "instance" { - continue - } - switch op.Action { - case "create": - createParams = convertParams(op.Params) - case "modify": - modifyParams = convertParams(op.Params) - case "suspend": - supportsSuspendDestroy = true - } - } - schemaParams := mergeParams(createParams, modifyParams) - createParams = alignParamTypes(createParams, schemaParams) - modifyParams = alignParamTypes(modifyParams, schemaParams) - createOnly := computeCreateOnly(createParams, modifyParams) - schemaParams = markCreateOnly(schemaParams, createOnly) - hasDomainParam := false - for _, param := range schemaParams { - if strings.EqualFold(strings.TrimSpace(param.Code), "domain") { - hasDomainParam = true - domainServiceIDsSet[spec.ServiceID] = struct{}{} - break - } - } - adoptExistingOnCreate := false - - suspendOnDestroy := true - if spec.Lifecycle.SuspendOnDestroyDefault != nil { - suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault - } - - gr := GenResource{ - Name: spec.Name, - ServiceID: spec.ServiceID, - CreateParams: createParams, - ModifyParams: modifyParams, - SchemaParams: schemaParams, - CreateOnlyParams: filterCreateOnly(schemaParams), - CreateOnlyRequiredParams: filterCreateOnlyRequired(schemaParams), - OutputParams: normalizeOutputParams(spec.Outputs.Params), - HasRefSvcParams: hasRefSvcParams(schemaParams), - SupportsSuspendDestroy: supportsSuspendDestroy, - SuspendOnDestroy: suspendOnDestroy, - AdoptExistingOnCreate: adoptExistingOnCreate, - HasDomainParam: hasDomainParam, - } - analyzeParams(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) - gr.NeedsJsonPlanMod = analyzeJsonPlanMod(gr.SchemaParams) - // NeedsStringsImport = true если: - // 1) есть ref_svc params (RefSvcId > 0, != 12) — шаблон генерирует restore-casing блоки с strings.EqualFold - // 2) есть строковые ModifyParams — шаблон генерирует EqualFold в hasServiceParamChanges (Update) - // 3) есть строковые CreateOnlyParams — шаблон генерирует EqualFold в create-only validation (ModifyPlan) - // RefSvcId=12 (S3 Object Storage) НЕ генерирует restore-блок — для S3 UUID обязателен точный формат - gr.NeedsStringsImport = hasRestoreCasingParams(gr.SchemaParams) || analyzeNeedsStrings(gr.ModifyParams) || analyzeNeedsStrings(gr.CreateOnlyRequiredParams) || analyzeNeedsStrings(gr.CreateOnlyParams) - - // --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) --- - // Правила из ARCHITECTURE.md: - // - redeploy → встроить как git_revision в основной ресурс - // - restart, recovery, reconcile → исключить (ручные операции, только UI) - // - всё остальное → стандартный action-ресурс (на будущее, если появятся новые) - for _, op := range spec.Operations { - if op.Kind != "action" { - continue - } - switch op.Action { - case "redeploy": - gr.HasRedeploy = true - case "restart", "recovery", "reconcile": - // Исключены - default: - act := GenAction{ - ServiceName: spec.Name, - ServiceID: spec.ServiceID, - ActionName: op.Action, - OperationName: op.Name, - Params: convertParams(op.Params), - } - act.SchemaParams = act.Params - analyzeParams(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams) - act.NeedsJsonPlanMod = analyzeJsonPlanMod(act.SchemaParams) - act.NeedsStringsImport = analyzeNeedsStrings(act.SchemaParams) - actions = append(actions, act) - } - } - - services = append(services, gr) - - srByName := map[string]*GenSubresource{} - for _, op := range spec.Operations { - if op.Kind != "subresource" { - continue - } - name := strings.TrimSpace(op.Subresource) - if name == "" { - continue - } - sr := srByName[name] - if sr == nil { - sr = &GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name} - srByName[name] = sr - } - switch op.Action { - case "create": - sr.CreateOpName = op.Name - sr.CreateParams = convertParams(op.Params) - case "modify": - sr.ModifyOpName = op.Name - sr.ModifyParams = convertParams(op.Params) - case "delete": - sr.DeleteOpName = op.Name - sr.DeleteParams = convertParams(op.Params) - } - } - for _, sr := range srByName { - sr.SchemaParams = mergeParams(sr.CreateParams, sr.ModifyParams, sr.DeleteParams) - sr.CreateParams = alignParamTypes(sr.CreateParams, sr.SchemaParams) - sr.ModifyParams = alignParamTypes(sr.ModifyParams, sr.SchemaParams) - sr.DeleteParams = alignParamTypes(sr.DeleteParams, sr.SchemaParams) - createOnly := computeCreateOnly(sr.CreateParams, sr.ModifyParams) - sr.SchemaParams = markCreateOnly(sr.SchemaParams, createOnly) - forceNewCodes := buildSubresourceForceNewCodes(sr, createOnly) - sr.SchemaParams = markForceNew(sr.SchemaParams, forceNewCodes) - sr.IdentityParams = filterForceNew(sr.SchemaParams) - if len(sr.IdentityParams) == 0 { - sr.IdentityParams = sr.SchemaParams - } - sr.HasRefSvcParams = hasRefSvcParams(sr.SchemaParams) - analyzeParams(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams) - analyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams) - sr.NeedsJsonPlanMod = analyzeJsonPlanMod(sr.SchemaParams) - sr.NeedsStringsImport = analyzeNeedsStrings(sr.ModifyParams) - subs = append(subs, *sr) - } - - return nil - }) - - if walkErr != nil { - return nil, nil, nil, walkErr - } - - domainServiceIDs := make([]int, 0, len(domainServiceIDsSet)) - for serviceID := range domainServiceIDsSet { - domainServiceIDs = append(domainServiceIDs, serviceID) - } - sort.Ints(domainServiceIDs) - for idx := range services { - if len(domainServiceIDs) == 0 { - services[idx].DomainServiceIDs = nil - continue - } - services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...) - } - - sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) - sort.Slice(subs, func(i, j int) bool { - if subs[i].ServiceName == subs[j].ServiceName { - return subs[i].SubName < subs[j].SubName - } - return subs[i].ServiceName < subs[j].ServiceName - }) - sort.Slice(actions, func(i, j int) bool { - if actions[i].ServiceName == actions[j].ServiceName { - return actions[i].ActionName < actions[j].ActionName - } - return actions[i].ServiceName < actions[j].ServiceName - }) - - return services, subs, actions, nil -} - -func convertParams(params []ParamSpec) []Param { - out := make([]Param, 0, len(params)) - for _, p := range params { - typeName := normalizeParamType(p.DataType) - defVal := normalizeDefault(p.Default) - ref := 0 - if p.RefSvcId != nil { - ref = *p.RefSvcId - } - out = append(out, Param{ - ID: p.ID, - Code: p.Code, - Type: typeName, - Required: p.Required, - Default: defVal, - RefSvcId: ref, - Descr: strings.TrimSpace(p.Descr), - Man: strings.TrimSpace(p.Man), - Sensitive: p.Sensitive, - // IsJson: флаг для добавления JsonNormalize план-модификатора в схему. - IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"), - }) - } - return out -} - -func normalizeParamType(value string) string { - v := strings.ToLower(strings.TrimSpace(value)) - if strings.Contains(v, "bool") { - return "bool" - } - if strings.Contains(v, "int") || strings.Contains(v, "number") { - return "int64" - } - return "string" -} - -func normalizeDefault(value interface{}) string { - if value == nil { - return "" - } - switch t := value.(type) { - case string: - return strings.TrimSpace(t) - default: - return fmt.Sprintf("%v", t) - } -} - -func normalizeOutputParams(params []OutputParam) []OutputParam { - if len(params) == 0 { - return []OutputParam{} - } - return params -} - -func hasRefSvcParams(params []Param) bool { - for _, p := range params { - if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") { - return true - } - } - return false -} - diff --git a/universal_rebuild/tools/gen_v2/loader/loader.go b/universal_rebuild/tools/gen_v2/loader/loader.go new file mode 100644 index 0000000..0d65c15 --- /dev/null +++ b/universal_rebuild/tools/gen_v2/loader/loader.go @@ -0,0 +1,313 @@ +// Package loader — загрузка YAML-спеков и построение ресурсов. +package loader + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "terraform-provider-nubes/tools/gen_v2/params" + "terraform-provider-nubes/tools/gen_v2/types" +) + +// LoadSpecs загружает все YAML-спеки из директории и строит GenResource/GenSubresource/GenAction. +func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, error) { + var services []types.GenResource + var subs []types.GenSubresource + var actions []types.GenAction + domainServiceIDsSet := map[int]struct{}{} + 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 types.ServiceSpec + if err := yaml.Unmarshal(b, &spec); err != nil { + return err + } + + // P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях. + if err := ValidateSpec(path, &spec); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + + createParams := []types.Param{} + modifyParams := []types.Param{} + supportsSuspendDestroy := false + for _, op := range spec.Operations { + if op.Kind != "instance" { + continue + } + switch op.Action { + case "create": + createParams = ConvertParams(op.Params) + case "modify": + modifyParams = ConvertParams(op.Params) + case "suspend": + supportsSuspendDestroy = true + } + } + schemaParams := params.Merge(createParams, modifyParams) + createParams = params.AlignParamTypes(createParams, schemaParams) + modifyParams = params.AlignParamTypes(modifyParams, schemaParams) + createOnly := params.ComputeCreateOnly(createParams, modifyParams) + schemaParams = params.MarkCreateOnly(schemaParams, createOnly) + hasDomainParam := false + for _, param := range schemaParams { + if strings.EqualFold(strings.TrimSpace(param.Code), "domain") { + hasDomainParam = true + domainServiceIDsSet[spec.ServiceID] = struct{}{} + break + } + } + adoptExistingOnCreate := false + + suspendOnDestroy := true + if spec.Lifecycle.SuspendOnDestroyDefault != nil { + suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault + } + + gr := types.GenResource{ + Name: spec.Name, + ServiceID: spec.ServiceID, + CreateParams: createParams, + ModifyParams: modifyParams, + SchemaParams: schemaParams, + CreateOnlyParams: params.FilterCreateOnly(schemaParams), + CreateOnlyRequiredParams: params.FilterCreateOnlyRequired(schemaParams), + OutputParams: NormalizeOutputParams(spec.Outputs.Params), + HasRefSvcParams: HasRefSvcParams(schemaParams), + SupportsSuspendDestroy: supportsSuspendDestroy, + SuspendOnDestroy: suspendOnDestroy, + AdoptExistingOnCreate: adoptExistingOnCreate, + HasDomainParam: hasDomainParam, + } + params.Analyze(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) + gr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(gr.SchemaParams) + gr.NeedsStringsImport = params.HasRestoreCasingParams(gr.SchemaParams) || params.AnalyzeNeedsStrings(gr.ModifyParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyRequiredParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyParams) + + // --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) --- + for _, op := range spec.Operations { + if op.Kind != "action" { + continue + } + switch op.Action { + case "redeploy": + gr.HasRedeploy = true + case "restart", "recovery", "reconcile": + // Исключены + default: + act := types.GenAction{ + ServiceName: spec.Name, + ServiceID: spec.ServiceID, + ActionName: op.Action, + OperationName: op.Name, + Params: ConvertParams(op.Params), + } + act.SchemaParams = act.Params + params.Analyze(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams) + act.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(act.SchemaParams) + act.NeedsStringsImport = params.AnalyzeNeedsStrings(act.SchemaParams) + actions = append(actions, act) + } + } + + services = append(services, gr) + + srByName := map[string]*types.GenSubresource{} + for _, op := range spec.Operations { + if op.Kind != "subresource" { + continue + } + name := strings.TrimSpace(op.Subresource) + if name == "" { + continue + } + sr := srByName[name] + if sr == nil { + sr = &types.GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name} + srByName[name] = sr + } + switch op.Action { + case "create": + sr.CreateOpName = op.Name + sr.CreateParams = ConvertParams(op.Params) + case "modify": + sr.ModifyOpName = op.Name + sr.ModifyParams = ConvertParams(op.Params) + case "delete": + sr.DeleteOpName = op.Name + sr.DeleteParams = ConvertParams(op.Params) + } + } + for _, sr := range srByName { + sr.SchemaParams = params.Merge(sr.CreateParams, sr.ModifyParams, sr.DeleteParams) + sr.CreateParams = params.AlignParamTypes(sr.CreateParams, sr.SchemaParams) + sr.ModifyParams = params.AlignParamTypes(sr.ModifyParams, sr.SchemaParams) + sr.DeleteParams = params.AlignParamTypes(sr.DeleteParams, sr.SchemaParams) + createOnly := params.ComputeCreateOnly(sr.CreateParams, sr.ModifyParams) + sr.SchemaParams = params.MarkCreateOnly(sr.SchemaParams, createOnly) + forceNewCodes := params.BuildSubresourceForceNewCodes(sr, createOnly) + sr.SchemaParams = params.MarkForceNew(sr.SchemaParams, forceNewCodes) + sr.IdentityParams = params.FilterForceNew(sr.SchemaParams) + if len(sr.IdentityParams) == 0 { + sr.IdentityParams = sr.SchemaParams + } + sr.HasRefSvcParams = HasRefSvcParams(sr.SchemaParams) + params.Analyze(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams) + params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams) + sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams) + sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams) + subs = append(subs, *sr) + } + + return nil + }) + + if walkErr != nil { + return nil, nil, nil, walkErr + } + + domainServiceIDs := make([]int, 0, len(domainServiceIDsSet)) + for serviceID := range domainServiceIDsSet { + domainServiceIDs = append(domainServiceIDs, serviceID) + } + sort.Ints(domainServiceIDs) + for idx := range services { + if len(domainServiceIDs) == 0 { + services[idx].DomainServiceIDs = nil + continue + } + services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...) + } + + sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) + sort.Slice(subs, func(i, j int) bool { + if subs[i].ServiceName == subs[j].ServiceName { + return subs[i].SubName < subs[j].SubName + } + return subs[i].ServiceName < subs[j].ServiceName + }) + sort.Slice(actions, func(i, j int) bool { + if actions[i].ServiceName == actions[j].ServiceName { + return actions[i].ActionName < actions[j].ActionName + } + return actions[i].ServiceName < actions[j].ServiceName + }) + + return services, subs, actions, nil +} + +// ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов. +func ConvertParams(params []types.ParamSpec) []types.Param { + out := make([]types.Param, 0, len(params)) + for _, p := range params { + typeName := NormalizeParamType(p.DataType) + defVal := NormalizeDefault(p.Default) + ref := 0 + if p.RefSvcId != nil { + ref = *p.RefSvcId + } + out = append(out, types.Param{ + ID: p.ID, + Code: p.Code, + Type: typeName, + Required: p.Required, + Default: defVal, + RefSvcId: ref, + Descr: strings.TrimSpace(p.Descr), + Man: strings.TrimSpace(p.Man), + Sensitive: p.Sensitive, + IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"), + }) + } + return out +} + +// NormalizeParamType нормализует строку data_type → bool | int64 | string. +func NormalizeParamType(value string) string { + v := strings.ToLower(strings.TrimSpace(value)) + if strings.Contains(v, "bool") { + return "bool" + } + if strings.Contains(v, "int") || strings.Contains(v, "number") { + return "int64" + } + return "string" +} + +// NormalizeDefault нормализует значение по умолчанию в строку. +func NormalizeDefault(value interface{}) string { + if value == nil { + return "" + } + switch t := value.(type) { + case string: + return strings.TrimSpace(t) + default: + return fmt.Sprintf("%v", t) + } +} + +// NormalizeOutputParams нормализует выходные параметры. +func NormalizeOutputParams(params []types.OutputParam) []types.OutputParam { + if len(params) == 0 { + return []types.OutputParam{} + } + return params +} + +// HasRefSvcParams проверяет наличие ref_svc-параметров. +func HasRefSvcParams(params []types.Param) bool { + for _, p := range params { + if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") { + return true + } + } + return false +} + +// KnownKinds — допустимые значения Kind операций. +var KnownKinds = map[string]bool{ + "instance": true, + "subresource": true, + "action": true, +} + +// ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds. +// P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования. +func ValidateSpec(path string, spec *types.ServiceSpec) error { + if spec.Name == "" { + return fmt.Errorf("missing required field: name") + } + if spec.ServiceID <= 0 { + return fmt.Errorf("missing required field: service_id") + } + for i, op := range spec.Operations { + if op.Kind == "" { + return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name) + } + if !KnownKinds[op.Kind] { + return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action)", i, op.Name, op.Kind) + } + if op.Action == "" { + return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind) + } + if op.Kind == "subresource" && op.Subresource == "" { + return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name) + } + } + return nil +} diff --git a/universal_rebuild/tools/gen_v2/main.go b/universal_rebuild/tools/gen_v2/main.go index cc8008f..9791b91 100644 --- a/universal_rebuild/tools/gen_v2/main.go +++ b/universal_rebuild/tools/gen_v2/main.go @@ -16,10 +16,12 @@ package main import ( - "fmt" "os" "path/filepath" "strings" + + "terraform-provider-nubes/tools/gen_v2/loader" + "terraform-provider-nubes/tools/gen_v2/writers" ) func main() { @@ -36,61 +38,27 @@ func main() { outDir = filepath.Join(root, "internal", "resources_gen") } - instanceResources, subresources, actions, err := loadSpecs(resourcesDir) + instanceResources, subresources, actions, err := loader.LoadSpecs(resourcesDir) if err != nil { panic(err) } for _, svc := range instanceResources { - if err := writeInstanceResource(outDir, svc); err != nil { + if err := writers.WriteInstanceResource(outDir, svc); err != nil { panic(err) } } for _, sr := range subresources { - if err := writeSubresource(outDir, sr); err != nil { + if err := writers.WriteSubresource(outDir, sr); err != nil { panic(err) } } for _, act := range actions { - if err := writeActionResource(outDir, act); err != nil { + if err := writers.WriteActionResource(outDir, act); err != nil { panic(err) } } - if err := writeRegistry(outDir, instanceResources, subresources, actions); err != nil { + if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions); err != nil { panic(err) } } - -// knownKinds — допустимые значения Kind операций. -var knownKinds = map[string]bool{ - "instance": true, - "subresource": true, - "action": true, -} - -// validateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds. -// P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования. -func validateSpec(path string, spec *ServiceSpec) error { - if spec.Name == "" { - return fmt.Errorf("missing required field: name") - } - if spec.ServiceID <= 0 { - return fmt.Errorf("missing required field: service_id") - } - for i, op := range spec.Operations { - if op.Kind == "" { - return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name) - } - if !knownKinds[op.Kind] { - return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action)", i, op.Name, op.Kind) - } - if op.Action == "" { - return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind) - } - if op.Kind == "subresource" && op.Subresource == "" { - return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name) - } - } - return nil -} - diff --git a/universal_rebuild/tools/gen_v2/params.go b/universal_rebuild/tools/gen_v2/params/params.go similarity index 55% rename from universal_rebuild/tools/gen_v2/params.go rename to universal_rebuild/tools/gen_v2/params/params.go index c667128..97c2e24 100644 --- a/universal_rebuild/tools/gen_v2/params.go +++ b/universal_rebuild/tools/gen_v2/params/params.go @@ -1,13 +1,16 @@ -// params — слияние, фильтрация, анализ параметров и ForceNew. -package main +// Package params — слияние, фильтрация, анализ параметров и ForceNew. +package params import ( "sort" "strings" + + "terraform-provider-nubes/tools/gen_v2/types" ) -func mergeParams(sets ...[]Param) []Param { - seen := map[string]Param{} +// Merge объединяет несколько наборов параметров, убирая дубликаты по Code. +func Merge(sets ...[]types.Param) []types.Param { + seen := map[string]types.Param{} for _, s := range sets { for _, p := range s { key := strings.ToLower(strings.TrimSpace(p.Code)) @@ -19,7 +22,7 @@ func mergeParams(sets ...[]Param) []Param { } } } - out := make([]Param, 0, len(seen)) + out := make([]types.Param, 0, len(seen)) for _, p := range seen { out = append(out, p) } @@ -27,7 +30,8 @@ func mergeParams(sets ...[]Param) []Param { return out } -func alignParamTypes(params []Param, schema []Param) []Param { +// AlignParamTypes выравнивает типы параметров по схеме. +func AlignParamTypes(params []types.Param, schema []types.Param) []types.Param { if len(params) == 0 || len(schema) == 0 { return params } @@ -58,7 +62,8 @@ func alignParamTypes(params []Param, schema []Param) []Param { return params } -func computeCreateOnly(createParams []Param, modifyParams []Param) map[string]struct{} { +// ComputeCreateOnly вычисляет набор полей, которые есть только в create (не в modify). +func ComputeCreateOnly(createParams []types.Param, modifyParams []types.Param) map[string]struct{} { modifyCodes := make(map[string]struct{}, len(modifyParams)) for _, p := range modifyParams { key := strings.ToLower(strings.TrimSpace(p.Code)) @@ -82,7 +87,8 @@ func computeCreateOnly(createParams []Param, modifyParams []Param) map[string]st return createOnly } -func markCreateOnly(schema []Param, createOnly map[string]struct{}) []Param { +// MarkCreateOnly проставляет CreateOnly=true для полей, которых нет в modify. +func MarkCreateOnly(schema []types.Param, createOnly map[string]struct{}) []types.Param { if len(schema) == 0 || len(createOnly) == 0 { return schema } @@ -100,12 +106,13 @@ func markCreateOnly(schema []Param, createOnly map[string]struct{}) []Param { return schema } -func filterCreateOnly(schema []Param) []Param { +// FilterCreateOnly возвращает только CreateOnly-параметры. +func FilterCreateOnly(schema []types.Param) []types.Param { if len(schema) == 0 { - return []Param{} + return []types.Param{} } - result := make([]Param, 0, len(schema)) + result := make([]types.Param, 0, len(schema)) for _, p := range schema { if p.CreateOnly { result = append(result, p) @@ -114,12 +121,13 @@ func filterCreateOnly(schema []Param) []Param { return result } -func filterCreateOnlyRequired(schema []Param) []Param { +// FilterCreateOnlyRequired возвращает обязательные CreateOnly-параметры. +func FilterCreateOnlyRequired(schema []types.Param) []types.Param { if len(schema) == 0 { - return []Param{} + return []types.Param{} } - result := make([]Param, 0, len(schema)) + result := make([]types.Param, 0, len(schema)) for _, p := range schema { if p.CreateOnly && p.Required { result = append(result, p) @@ -128,7 +136,8 @@ func filterCreateOnlyRequired(schema []Param) []Param { return result } -func analyzeParams(usesBool *bool, usesInt64 *bool, usesString *bool, hasDefaults *bool, needsBoolDefault *bool, needsInt64Default *bool, needsStringDefault *bool, params []Param) { +// Analyze анализирует типы параметров и проставляет флаги uses/needs. +func Analyze(usesBool *bool, usesInt64 *bool, usesString *bool, hasDefaults *bool, needsBoolDefault *bool, needsInt64Default *bool, needsStringDefault *bool, params []types.Param) { for _, p := range params { switch strings.ToLower(p.Type) { case "bool": @@ -153,8 +162,8 @@ func analyzeParams(usesBool *bool, usesInt64 *bool, usesString *bool, hasDefault } } - -func buildSubresourceForceNewCodes(sr *GenSubresource, createOnly map[string]struct{}) map[string]struct{} { +// BuildSubresourceForceNewCodes строит карту параметров, требующих ForceNew. +func BuildSubresourceForceNewCodes(sr *types.GenSubresource, createOnly map[string]struct{}) map[string]struct{} { forceNew := map[string]struct{}{} if sr.ModifyOpName == "" { for _, p := range sr.SchemaParams { @@ -177,7 +186,8 @@ func buildSubresourceForceNewCodes(sr *GenSubresource, createOnly map[string]str return forceNew } -func markForceNew(params []Param, forceNew map[string]struct{}) []Param { +// MarkForceNew проставляет ForceNew=true для указанных параметров. +func MarkForceNew(params []types.Param, forceNew map[string]struct{}) []types.Param { if len(params) == 0 || len(forceNew) == 0 { return params } @@ -193,8 +203,9 @@ func markForceNew(params []Param, forceNew map[string]struct{}) []Param { return params } -func filterForceNew(params []Param) []Param { - result := []Param{} +// FilterForceNew возвращает только ForceNew-параметры. +func FilterForceNew(params []types.Param) []types.Param { + result := []types.Param{} for _, p := range params { if p.ForceNew { result = append(result, p) @@ -203,9 +214,8 @@ func filterForceNew(params []Param) []Param { return result } -// analyzeJsonPlanMod возвращает true, если хотя бы один параметр является JSON-строкой. -// Используется для условного добавления импорта planmodifier в сгенерированные файлы. -func analyzeJsonPlanMod(params []Param) bool { +// AnalyzeJsonPlanMod возвращает true, если хотя бы один параметр — JSON. +func AnalyzeJsonPlanMod(params []types.Param) bool { for _, p := range params { if p.IsJson { return true @@ -214,9 +224,8 @@ func analyzeJsonPlanMod(params []Param) bool { return false } -// hasRestoreCasingParams возвращает true, если есть ref_svc params с RefSvcId > 0 && != 12 -// (restore-casing блок в Create/Read/Update использует strings.EqualFold для них). -func hasRestoreCasingParams(params []Param) bool { +// HasRestoreCasingParams возвращает true, если есть ref_svc-параметры (кроме S3). +func HasRestoreCasingParams(params []types.Param) bool { for _, p := range params { if p.RefSvcId > 0 && p.RefSvcId != 12 && strings.EqualFold(strings.TrimSpace(p.Type), "string") { return true @@ -225,9 +234,8 @@ func hasRestoreCasingParams(params []Param) bool { return false } -// analyzeNeedsStrings возвращает true, если среди params есть хотя бы один строковый параметр. -// Используется для условного добавления импорта "strings" (EqualFold в Update). -func analyzeNeedsStrings(params []Param) bool { +// AnalyzeNeedsStrings возвращает true, если есть строковые параметры. +func AnalyzeNeedsStrings(params []types.Param) bool { for _, p := range params { t := strings.ToLower(strings.TrimSpace(p.Type)) if t != "bool" && t != "int" && t != "int64" && t != "number" { @@ -237,7 +245,8 @@ func analyzeNeedsStrings(params []Param) bool { return false } -func analyzePlanModifiers(needsBool *bool, needsInt64 *bool, needsString *bool, params []Param) { +// AnalyzePlanModifiers анализирует ForceNew-параметры для добавления planmodifier-импортов. +func AnalyzePlanModifiers(needsBool *bool, needsInt64 *bool, needsString *bool, params []types.Param) { for _, p := range params { if !p.ForceNew { continue diff --git a/universal_rebuild/tools/gen_v2/templates.go b/universal_rebuild/tools/gen_v2/templates/templates.go similarity index 99% rename from universal_rebuild/tools/gen_v2/templates.go rename to universal_rebuild/tools/gen_v2/templates/templates.go index c896a9c..887dec9 100644 --- a/universal_rebuild/tools/gen_v2/templates.go +++ b/universal_rebuild/tools/gen_v2/templates/templates.go @@ -1,6 +1,6 @@ -package main +package templates -const instanceTemplate = `package resources_gen +const Instance = `package resources_gen import ( "context" @@ -608,7 +608,7 @@ func (r *{{ToCamel .Name}}Resource) Configure(_ context.Context, req resource.Co } ` -const subresourceTemplate = `package resources_gen +const Subresource = `package resources_gen import ( "context" @@ -1052,7 +1052,7 @@ func (r *{{ToCamel (printf "%s_%s" .ServiceName .SubName)}}Resource) Configure(_ } ` -const actionTemplate = `package resources_gen +const Action = `package resources_gen import ( "context" diff --git a/universal_rebuild/tools/gen_v2/types.go b/universal_rebuild/tools/gen_v2/types/types.go similarity index 91% rename from universal_rebuild/tools/gen_v2/types.go rename to universal_rebuild/tools/gen_v2/types/types.go index 9dceab8..c866a76 100644 --- a/universal_rebuild/tools/gen_v2/types.go +++ b/universal_rebuild/tools/gen_v2/types/types.go @@ -1,6 +1,5 @@ -// types — структуры данных для генератора Terraform-провайдера. -package main - +// Package types — структуры данных для генератора Terraform-провайдера. +package types // ─── Входные структуры (из YAML) ─────────────────────────────────────────── @@ -50,6 +49,7 @@ type ServiceSpec struct { Operations []OperationSpec `yaml:"operations"` } +// Param — нормализованный параметр (после конвертации из ParamSpec). type Param struct { ID int Code string @@ -68,6 +68,7 @@ type Param struct { IsJson bool } +// GenResource — ресурс инстанса (nubes_{service}). type GenResource struct { Name string ServiceID int @@ -103,6 +104,7 @@ type GenResource struct { NeedsStringsImport bool } +// GenSubresource — подресурс (nubes_{service}_{sub}). type GenSubresource struct { ServiceName string ServiceID int @@ -126,12 +128,11 @@ type GenSubresource struct { NeedsBoolPlanMod bool NeedsInt64PlanMod bool NeedsStringPlanMod bool - // NeedsJsonPlanMod: хотя бы один атрибут имеет IsJson=true. - NeedsJsonPlanMod bool - // NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update). + NeedsJsonPlanMod bool NeedsStringsImport bool } +// GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource). type GenAction struct { ServiceName string ServiceID int @@ -146,8 +147,6 @@ type GenAction struct { NeedsBoolDefault bool NeedsInt64Default bool NeedsStringDefault bool - // NeedsJsonPlanMod: хотя бы один атрибут имеет IsJson=true. - NeedsJsonPlanMod bool - // NeedsStringsImport: есть строковые SchemaParams (нужен EqualFold). + NeedsJsonPlanMod bool NeedsStringsImport bool } diff --git a/universal_rebuild/tools/gen_v2/writers.go b/universal_rebuild/tools/gen_v2/writers.go deleted file mode 100644 index d81c8d7..0000000 --- a/universal_rebuild/tools/gen_v2/writers.go +++ /dev/null @@ -1,128 +0,0 @@ -// writers — генерация Go-файлов ресурсов и registry.go. -package main - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "text/template" -) - -func writeInstanceResource(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, - "ParamDefaultExpr": paramDefaultExpr, - "ParamFormat": paramFormat, - "ParamParse": paramParse, - "ParamDescription": paramDescription, - "OutputType": outputParamType, - "OutputIsMap": outputParamIsMap, - "OutputIsList": outputParamIsList, - "OutputSensitive": outputParamSensitive, - "bt": func() string { return "`" }, - }).Parse(instanceTemplate) - if err != nil { - return err - } - - var buf bytes.Buffer - if err := tpl.Execute(&buf, svc); err != nil { - return err - } - - formatted := formatSourceOrWarn(filePath, buf.Bytes()) - - return os.WriteFile(filePath, formatted, 0644) -} - -func writeSubresource(outDir string, sr GenSubresource) error { - name := fmt.Sprintf("%s_%s", sr.ServiceName, sr.SubName) - fileName := fmt.Sprintf("%d_%s_resource.go", sr.ServiceID, name) - filePath := filepath.Join(outDir, fileName) - - tpl, err := template.New("subresource").Funcs(template.FuncMap{ - "ToCamel": toCamel, - "ToSnake": toSnake, - "ParamType": paramType, - "ParamDefaultExpr": paramDefaultExpr, - "ParamFormat": paramFormat, - "ParamParse": paramParse, - "ParamDescription": paramDescription, - "PlanModifierType": planModifierType, - "PlanModifierExpr": planModifierExpr, - "bt": func() string { return "`" }, - }).Parse(subresourceTemplate) - if err != nil { - return err - } - - var buf bytes.Buffer - if err := tpl.Execute(&buf, sr); err != nil { - return err - } - - formatted := formatSourceOrWarn(filePath, buf.Bytes()) - - return os.WriteFile(filePath, formatted, 0644) -} - -func writeActionResource(outDir string, act GenAction) error { - name := fmt.Sprintf("%s_%s", act.ServiceName, act.ActionName) - fileName := fmt.Sprintf("%d_%s_action.go", act.ServiceID, name) - filePath := filepath.Join(outDir, fileName) - - tpl, err := template.New("action").Funcs(template.FuncMap{ - "ToCamel": toCamel, - "ToSnake": toSnake, - "ParamType": paramType, - "ParamDefaultExpr": paramDefaultExpr, - "ParamFormat": paramFormat, - "ParamParse": paramParse, - "ParamDescription": paramDescription, - "bt": func() string { return "`" }, - }).Parse(actionTemplate) - if err != nil { - return err - } - - var buf bytes.Buffer - if err := tpl.Execute(&buf, act); err != nil { - return err - } - - formatted := formatSourceOrWarn(filePath, buf.Bytes()) - - return os.WriteFile(filePath, formatted, 0644) -} - -func writeRegistry(outDir string, services []GenResource, subs []GenSubresource, actions []GenAction) 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_v2. 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))) - } - for _, sr := range subs { - buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", toCamel(sr.ServiceName+"_"+sr.SubName))) - } - for _, act := range actions { - buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", toCamel(act.ServiceName+"_"+act.ActionName))) - } - buf.WriteString("\t}\n") - buf.WriteString("}\n") - - regPath := filepath.Join(outDir, "registry.go") - formatted := formatSourceOrWarn(regPath, buf.Bytes()) - - return os.WriteFile(regPath, formatted, 0644) -} - diff --git a/universal_rebuild/tools/gen_v2/writers/writers.go b/universal_rebuild/tools/gen_v2/writers/writers.go new file mode 100644 index 0000000..77ecf70 --- /dev/null +++ b/universal_rebuild/tools/gen_v2/writers/writers.go @@ -0,0 +1,135 @@ +// Package writers — генерация Go-файлов ресурсов и registry.go. +package writers + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "text/template" + + "terraform-provider-nubes/tools/gen_v2/helpers" + "terraform-provider-nubes/tools/gen_v2/templates" + "terraform-provider-nubes/tools/gen_v2/types" +) + +// WriteInstanceResource генерирует nubes_{service} (CRUD инстанса). +func WriteInstanceResource(outDir string, svc types.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": helpers.ToCamel, + "ToSnake": helpers.ToSnake, + "ParamType": helpers.ParamType, + "ParamDefaultExpr": helpers.ParamDefaultExpr, + "ParamFormat": helpers.ParamFormat, + "ParamParse": helpers.ParamParse, + "ParamDescription": helpers.ParamDescription, + "OutputType": helpers.OutputParamType, + "OutputIsMap": helpers.OutputParamIsMap, + "OutputIsList": helpers.OutputParamIsList, + "OutputSensitive": helpers.OutputParamSensitive, + "bt": func() string { return "`" }, + }).Parse(templates.Instance) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, svc); err != nil { + return err + } + + formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + + return os.WriteFile(filePath, formatted, 0644) +} + +// WriteSubresource генерирует nubes_{service}_{sub} (пользователи, базы, ...). +func WriteSubresource(outDir string, sr types.GenSubresource) error { + name := fmt.Sprintf("%s_%s", sr.ServiceName, sr.SubName) + fileName := fmt.Sprintf("%d_%s_resource.go", sr.ServiceID, name) + filePath := filepath.Join(outDir, fileName) + + tpl, err := template.New("subresource").Funcs(template.FuncMap{ + "ToCamel": helpers.ToCamel, + "ToSnake": helpers.ToSnake, + "ParamType": helpers.ParamType, + "ParamDefaultExpr": helpers.ParamDefaultExpr, + "ParamFormat": helpers.ParamFormat, + "ParamParse": helpers.ParamParse, + "ParamDescription": helpers.ParamDescription, + "PlanModifierType": helpers.PlanModifierType, + "PlanModifierExpr": helpers.PlanModifierExpr, + "bt": func() string { return "`" }, + }).Parse(templates.Subresource) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, sr); err != nil { + return err + } + + formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + + return os.WriteFile(filePath, formatted, 0644) +} + +// WriteActionResource генерирует action-ресурс (почти не используется). +func WriteActionResource(outDir string, act types.GenAction) error { + name := fmt.Sprintf("%s_%s", act.ServiceName, act.ActionName) + fileName := fmt.Sprintf("%d_%s_action.go", act.ServiceID, name) + filePath := filepath.Join(outDir, fileName) + + tpl, err := template.New("action").Funcs(template.FuncMap{ + "ToCamel": helpers.ToCamel, + "ToSnake": helpers.ToSnake, + "ParamType": helpers.ParamType, + "ParamDefaultExpr": helpers.ParamDefaultExpr, + "ParamFormat": helpers.ParamFormat, + "ParamParse": helpers.ParamParse, + "ParamDescription": helpers.ParamDescription, + "bt": func() string { return "`" }, + }).Parse(templates.Action) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, act); err != nil { + return err + } + + formatted := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + + return os.WriteFile(filePath, formatted, 0644) +} + +// WriteRegistry генерирует registry.go со списком всех ресурсов. +func WriteRegistry(outDir string, services []types.GenResource, subs []types.GenSubresource, actions []types.GenAction) 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_v2. 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", helpers.ToCamel(svc.Name))) + } + for _, sr := range subs { + buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(sr.ServiceName+"_"+sr.SubName))) + } + for _, act := range actions { + buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(act.ServiceName+"_"+act.ActionName))) + } + buf.WriteString("\t}\n") + buf.WriteString("}\n") + + regPath := filepath.Join(outDir, "registry.go") + formatted := helpers.FormatSourceOrWarn(regPath, buf.Bytes()) + + return os.WriteFile(regPath, formatted, 0644) +}