From e8935f1ebf7ef4acf661bd9f3c0fb54311e1589e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 5 Jul 2026 09:03:16 +0400 Subject: [PATCH] refactor(gen_v2): split into 7 files by concern (types, loader, params, writers, helpers, templates, main) --- .../tools/gen_v2/generate_resources_v2.go | 1050 -------- .../tools/gen_v2/generate_resources_v2.go.bak | 2277 +++++++++++++++++ universal_rebuild/tools/gen_v2/helpers.go | 176 ++ universal_rebuild/tools/gen_v2/loader.go | 282 ++ universal_rebuild/tools/gen_v2/main.go | 96 + universal_rebuild/tools/gen_v2/params.go | 254 ++ universal_rebuild/tools/gen_v2/types.go | 153 ++ universal_rebuild/tools/gen_v2/writers.go | 128 + 8 files changed, 3366 insertions(+), 1050 deletions(-) delete mode 100644 universal_rebuild/tools/gen_v2/generate_resources_v2.go create mode 100644 universal_rebuild/tools/gen_v2/generate_resources_v2.go.bak create mode 100644 universal_rebuild/tools/gen_v2/helpers.go create mode 100644 universal_rebuild/tools/gen_v2/loader.go create mode 100644 universal_rebuild/tools/gen_v2/main.go create mode 100644 universal_rebuild/tools/gen_v2/params.go create mode 100644 universal_rebuild/tools/gen_v2/types.go create mode 100644 universal_rebuild/tools/gen_v2/writers.go diff --git a/universal_rebuild/tools/gen_v2/generate_resources_v2.go b/universal_rebuild/tools/gen_v2/generate_resources_v2.go deleted file mode 100644 index 6366184..0000000 --- a/universal_rebuild/tools/gen_v2/generate_resources_v2.go +++ /dev/null @@ -1,1050 +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 -} - -// 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/generate_resources_v2.go.bak b/universal_rebuild/tools/gen_v2/generate_resources_v2.go.bak new file mode 100644 index 0000000..7465552 --- /dev/null +++ b/universal_rebuild/tools/gen_v2/generate_resources_v2.go.bak @@ -0,0 +1,2277 @@ +// 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.go new file mode 100644 index 0000000..4d1ca4a --- /dev/null +++ b/universal_rebuild/tools/gen_v2/helpers.go @@ -0,0 +1,176 @@ +// helpers — конвертация имён, форматирование, plan-модификаторы. +package main + +import ( + "fmt" + "go/format" + "os" + "strings" + "unicode" +) + +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 +} + + +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 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/loader.go b/universal_rebuild/tools/gen_v2/loader.go new file mode 100644 index 0000000..b98296c --- /dev/null +++ b/universal_rebuild/tools/gen_v2/loader.go @@ -0,0 +1,282 @@ +// 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/main.go b/universal_rebuild/tools/gen_v2/main.go new file mode 100644 index 0000000..cc8008f --- /dev/null +++ b/universal_rebuild/tools/gen_v2/main.go @@ -0,0 +1,96 @@ +// 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 ( + "fmt" + "os" + "path/filepath" + "strings" +) + +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) + } +} + +// 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.go new file mode 100644 index 0000000..c667128 --- /dev/null +++ b/universal_rebuild/tools/gen_v2/params.go @@ -0,0 +1,254 @@ +// params — слияние, фильтрация, анализ параметров и ForceNew. +package main + +import ( + "sort" + "strings" +) + +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 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 + } + } +} diff --git a/universal_rebuild/tools/gen_v2/types.go b/universal_rebuild/tools/gen_v2/types.go new file mode 100644 index 0000000..9dceab8 --- /dev/null +++ b/universal_rebuild/tools/gen_v2/types.go @@ -0,0 +1,153 @@ +// types — структуры данных для генератора Terraform-провайдера. +package main + + +// ─── Входные структуры (из 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 +} diff --git a/universal_rebuild/tools/gen_v2/writers.go b/universal_rebuild/tools/gen_v2/writers.go new file mode 100644 index 0000000..d81c8d7 --- /dev/null +++ b/universal_rebuild/tools/gen_v2/writers.go @@ -0,0 +1,128 @@ +// 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) +} +