// Package loader — загрузка YAML-спеков и построение ресурсов. package loader import ( "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" "resource-generator/internal/params" "resource-generator/internal/types" ) // LoadSpecs загружает все YAML-спеки из директории и строит GenResource/GenSubresource/GenAction. func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, error) { var services []types.GenResource var subs []types.GenSubresource var actions []types.GenAction domainServiceIDsSet := map[int]struct{}{} walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } b, err := os.ReadFile(path) if err != nil { return err } var spec types.ServiceSpec if err := yaml.Unmarshal(b, &spec); err != nil { return err } // P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях. if err := ValidateSpec(path, &spec); err != nil { return fmt.Errorf("%s: %w", path, err) } createParams := []types.Param{} modifyParams := []types.Param{} supportsSuspendDestroy := false for _, op := range spec.Operations { if op.Kind != "instance" { continue } switch op.Action { case "create": createParams = ConvertParams(op.Params) case "modify": modifyParams = ConvertParams(op.Params) case "suspend": supportsSuspendDestroy = true } } schemaParams := params.Merge(createParams, modifyParams) createParams = params.AlignParamTypes(createParams, schemaParams) modifyParams = params.AlignParamTypes(modifyParams, schemaParams) createOnly := params.ComputeCreateOnly(createParams, modifyParams) schemaParams = params.MarkCreateOnly(schemaParams, createOnly) hasDomainParam := false for _, param := range schemaParams { if strings.EqualFold(strings.TrimSpace(param.Code), "domain") { hasDomainParam = true domainServiceIDsSet[spec.ServiceID] = struct{}{} break } } adoptExistingOnCreate := false suspendOnDestroy := true if spec.Lifecycle.SuspendOnDestroyDefault != nil { suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault } gr := types.GenResource{ Name: spec.Name, ServiceID: spec.ServiceID, CreateParams: createParams, ModifyParams: modifyParams, SchemaParams: schemaParams, CreateOnlyParams: params.FilterCreateOnly(schemaParams), CreateOnlyRequiredParams: params.FilterCreateOnlyRequired(schemaParams), OutputParams: NormalizeOutputParams(spec.Outputs.Params), HasRefSvcParams: HasRefSvcParams(schemaParams), SupportsSuspendDestroy: supportsSuspendDestroy, SuspendOnDestroy: suspendOnDestroy, AdoptExistingOnCreate: adoptExistingOnCreate, HasDomainParam: hasDomainParam, } params.Analyze(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) gr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(gr.SchemaParams) gr.NeedsStringsImport = params.HasRestoreCasingParams(gr.SchemaParams) || params.AnalyzeNeedsStrings(gr.ModifyParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyRequiredParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyParams) // --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) --- for _, op := range spec.Operations { if op.Kind != "action" { continue } switch op.Action { case "redeploy": gr.HasRedeploy = true case "restart", "recovery", "reconcile": // Исключены default: act := types.GenAction{ ServiceName: spec.Name, ServiceID: spec.ServiceID, ActionName: op.Action, OperationName: op.Name, Params: ConvertParams(op.Params), } act.SchemaParams = act.Params params.Analyze(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams) act.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(act.SchemaParams) act.NeedsStringsImport = params.AnalyzeNeedsStrings(act.SchemaParams) actions = append(actions, act) } } services = append(services, gr) srByName := map[string]*types.GenSubresource{} for _, op := range spec.Operations { if op.Kind != "subresource" { continue } name := strings.TrimSpace(op.Subresource) if name == "" { continue } sr := srByName[name] if sr == nil { sr = &types.GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name} srByName[name] = sr } switch op.Action { case "create": sr.CreateOpName = op.Name sr.CreateParams = ConvertParams(op.Params) case "modify": sr.ModifyOpName = op.Name sr.ModifyParams = ConvertParams(op.Params) case "delete": sr.DeleteOpName = op.Name sr.DeleteParams = ConvertParams(op.Params) } } for _, sr := range srByName { sr.SchemaParams = params.Merge(sr.CreateParams, sr.ModifyParams, sr.DeleteParams) sr.CreateParams = params.AlignParamTypes(sr.CreateParams, sr.SchemaParams) sr.ModifyParams = params.AlignParamTypes(sr.ModifyParams, sr.SchemaParams) sr.DeleteParams = params.AlignParamTypes(sr.DeleteParams, sr.SchemaParams) createOnly := params.ComputeCreateOnly(sr.CreateParams, sr.ModifyParams) sr.SchemaParams = params.MarkCreateOnly(sr.SchemaParams, createOnly) forceNewCodes := params.BuildSubresourceForceNewCodes(sr, createOnly) sr.SchemaParams = params.MarkForceNew(sr.SchemaParams, forceNewCodes) sr.IdentityParams = params.FilterForceNew(sr.SchemaParams) if len(sr.IdentityParams) == 0 { sr.IdentityParams = sr.SchemaParams } sr.HasRefSvcParams = HasRefSvcParams(sr.SchemaParams) params.Analyze(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams) params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams) sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams) sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams) subs = append(subs, *sr) } return nil }) if walkErr != nil { return nil, nil, nil, walkErr } domainServiceIDs := make([]int, 0, len(domainServiceIDsSet)) for serviceID := range domainServiceIDsSet { domainServiceIDs = append(domainServiceIDs, serviceID) } sort.Ints(domainServiceIDs) for idx := range services { if len(domainServiceIDs) == 0 { services[idx].DomainServiceIDs = nil continue } services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...) } sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) sort.Slice(subs, func(i, j int) bool { if subs[i].ServiceName == subs[j].ServiceName { return subs[i].SubName < subs[j].SubName } return subs[i].ServiceName < subs[j].ServiceName }) sort.Slice(actions, func(i, j int) bool { if actions[i].ServiceName == actions[j].ServiceName { return actions[i].ActionName < actions[j].ActionName } return actions[i].ServiceName < actions[j].ServiceName }) return services, subs, actions, nil } // ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов. func ConvertParams(params []types.ParamSpec) []types.Param { out := make([]types.Param, 0, len(params)) for _, p := range params { typeName := NormalizeParamType(p.DataType) defVal := NormalizeDefault(p.Default) ref := 0 if p.RefSvcId != nil { ref = *p.RefSvcId } out = append(out, types.Param{ ID: p.ID, Code: p.Code, Type: typeName, Required: p.Required, Default: defVal, RefSvcId: ref, Descr: strings.TrimSpace(p.Descr), Man: strings.TrimSpace(p.Man), Sensitive: p.Sensitive, IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"), }) } return out } // NormalizeParamType нормализует строку data_type → bool | int64 | string. func NormalizeParamType(value string) string { v := strings.ToLower(strings.TrimSpace(value)) if strings.Contains(v, "bool") { return "bool" } if strings.Contains(v, "int") || strings.Contains(v, "number") { return "int64" } return "string" } // NormalizeDefault нормализует значение по умолчанию в строку. func NormalizeDefault(value interface{}) string { if value == nil { return "" } switch t := value.(type) { case string: return strings.TrimSpace(t) default: return fmt.Sprintf("%v", t) } } // NormalizeOutputParams нормализует выходные параметры. func NormalizeOutputParams(params []types.OutputParam) []types.OutputParam { if len(params) == 0 { return []types.OutputParam{} } return params } // HasRefSvcParams проверяет наличие ref_svc-параметров. func HasRefSvcParams(params []types.Param) bool { for _, p := range params { if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") { return true } } return false } // KnownKinds — допустимые значения Kind операций. var KnownKinds = map[string]bool{ "instance": true, "subresource": true, "action": true, } // ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds. // P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования. func ValidateSpec(path string, spec *types.ServiceSpec) error { if spec.Name == "" { return fmt.Errorf("missing required field: name") } if spec.ServiceID <= 0 { return fmt.Errorf("missing required field: service_id") } for i, op := range spec.Operations { if op.Kind == "" { return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name) } if !KnownKinds[op.Kind] { return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action)", i, op.Name, op.Kind) } if op.Action == "" { return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind) } if op.Kind == "subresource" && op.Subresource == "" { return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name) } } return nil }