283 lines
9.4 KiB
Go
283 lines
9.4 KiB
Go
// 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
|
||
}
|
||
|