// 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 }