diff --git a/TOOLS/lib/types.go b/TOOLS/lib/types.go index b71d128..09c536f 100644 --- a/TOOLS/lib/types.go +++ b/TOOLS/lib/types.go @@ -41,6 +41,7 @@ type OperationSpec struct { ID int `yaml:"id"` Kind string `yaml:"kind"` Action string `yaml:"action"` + Modifier string `yaml:"modifier,omitempty"` Subresource string `yaml:"subresource,omitempty"` Man string `yaml:"man,omitempty"` Params []ParamSpec `yaml:"params"` diff --git a/TOOLS/resource-generator/internal/loader/loader.go b/TOOLS/resource-generator/internal/loader/loader.go index 9139d3b..b6364d6 100644 --- a/TOOLS/resource-generator/internal/loader/loader.go +++ b/TOOLS/resource-generator/internal/loader/loader.go @@ -24,11 +24,12 @@ import ( "resource-generator/internal/types" ) -// LoadSpecs загружает все YAML-спеки из директории и строит GenResource/GenSubresource/GenAction. -func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, error) { +// LoadSpecs загружает все YAML-спеки из директории и строит модели всех ресурсов. +func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, []types.GenModifier, error) { var services []types.GenResource var subs []types.GenSubresource var actions []types.GenAction + var modifiers []types.GenModifier domainServiceIDsSet := map[int]struct{}{} walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -56,6 +57,24 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types modifyParams := []types.Param{} supportsSuspendDestroy := false for _, op := range spec.Operations { + if op.Kind == "modifier" { + name := strings.TrimSpace(op.Modifier) + if name == "" { + name = strings.TrimSpace(op.Action) + } + modifier := types.GenModifier{ + ServiceName: spec.Name, + ServiceID: spec.ServiceID, + ModifierName: name, + OperationName: op.Action, + Params: ConvertParams(op.Params), + } + modifier.SchemaParams = modifier.Params + params.Analyze(&modifier.UsesBool, &modifier.UsesInt64, &modifier.UsesString, &modifier.HasDefaults, &modifier.NeedsBoolDefault, &modifier.NeedsInt64Default, &modifier.NeedsStringDefault, modifier.SchemaParams) + modifier.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(modifier.SchemaParams) + modifiers = append(modifiers, modifier) + continue + } if op.Kind != "instance" { continue } @@ -192,7 +211,7 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types }) if walkErr != nil { - return nil, nil, nil, walkErr + return nil, nil, nil, nil, walkErr } domainServiceIDs := make([]int, 0, len(domainServiceIDsSet)) @@ -221,8 +240,14 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types } return actions[i].ServiceName < actions[j].ServiceName }) + sort.Slice(modifiers, func(i, j int) bool { + if modifiers[i].ServiceName == modifiers[j].ServiceName { + return modifiers[i].ModifierName < modifiers[j].ModifierName + } + return modifiers[i].ServiceName < modifiers[j].ServiceName + }) - return services, subs, actions, nil + return services, subs, actions, modifiers, nil } // ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов. @@ -313,6 +338,7 @@ var KnownKinds = map[string]bool{ "instance": true, "subresource": true, "action": true, + "modifier": true, } // ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds. @@ -329,7 +355,7 @@ func ValidateSpec(path string, spec *types.ServiceSpec) error { 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) + return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action, modifier)", 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) @@ -337,6 +363,9 @@ func ValidateSpec(path string, spec *types.ServiceSpec) error { if op.Kind == "subresource" && op.Subresource == "" { return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name) } + if op.Kind == "modifier" && strings.TrimSpace(op.Action) == "" { + return fmt.Errorf("operation[%d] %q (kind=modifier): missing required field: action", i, op.Name) + } } return nil } diff --git a/TOOLS/resource-generator/internal/templates/modifier.go b/TOOLS/resource-generator/internal/templates/modifier.go new file mode 100644 index 0000000..a6ca433 --- /dev/null +++ b/TOOLS/resource-generator/internal/templates/modifier.go @@ -0,0 +1,130 @@ +package templates + +const Modifier = `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" + {{- if .NeedsBoolDefault }} + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + {{- end }} + {{- 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/resource-generator. DO NOT EDIT. +// Modifier: {{.ServiceName}}.{{.ModifierName}} + +var _ resource.Resource = &{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource{} + +type {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource struct { + client *core.UniversalClient +} + +type {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Model struct { + ID types.String ` + "`" + `tfsdk:"id"` + "`" + ` + {{ToCamel .ServiceName}}ID types.String ` + "`" + `tfsdk:"{{ToSnake .ServiceName}}_id"` + "`" + ` + 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 .ModifierName)}}Resource() resource.Resource { + return &{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource{} +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_{{.ServiceName}}_{{.ModifierName}}" +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}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}, + "operation_timeout": schema.StringAttribute{Optional: true}, + "log_level": schema.StringAttribute{Optional: true}, + {{- 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 .) "") }}Required: true,{{else}}Optional: true,{{end}} + {{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- 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 .ModifierName)}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { return } + instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) + if instanceUID == "" { resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра"); return } + params := resources_core.CompactParams(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}}", "{{.ModifierName}}")) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}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 .ModifierName)}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { return } + instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString()) + params := resources_core.CompactParams(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}}", "{{.ModifierName}}")) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // No-op: no confirmed inverse payload exists for this modifier. +} + +func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}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 +} +` \ No newline at end of file diff --git a/TOOLS/resource-generator/internal/types/types.go b/TOOLS/resource-generator/internal/types/types.go index 9ebb5d8..2041df3 100644 --- a/TOOLS/resource-generator/internal/types/types.go +++ b/TOOLS/resource-generator/internal/types/types.go @@ -145,3 +145,22 @@ type GenAction struct { NeedsJsonPlanMod bool NeedsStringsImport bool } + +// GenModifier — отдельный ресурс для отложенной parent-level modify операции. +// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён. +type GenModifier struct { + ServiceName string + ServiceID int + ModifierName string + OperationName string + Params []Param + SchemaParams []Param + UsesBool bool + UsesInt64 bool + UsesString bool + HasDefaults bool + NeedsBoolDefault bool + NeedsInt64Default bool + NeedsStringDefault bool + NeedsJsonPlanMod bool +} diff --git a/TOOLS/resource-generator/internal/writers/writers.go b/TOOLS/resource-generator/internal/writers/writers.go index 91d8683..68b67f7 100644 --- a/TOOLS/resource-generator/internal/writers/writers.go +++ b/TOOLS/resource-generator/internal/writers/writers.go @@ -135,8 +135,38 @@ func WriteActionResource(outDir string, act types.GenAction) error { return os.WriteFile(filePath, formatted, 0644) } +// WriteModifierResource генерирует отдельный ресурс для parent-level modify. +func WriteModifierResource(outDir string, modifier types.GenModifier) error { + name := fmt.Sprintf("%s_%s", modifier.ServiceName, modifier.ModifierName) + fileName := fmt.Sprintf("%d_%s_modifier.go", modifier.ServiceID, name) + filePath := filepath.Join(outDir, fileName) + + tpl, err := template.New("modifier").Funcs(template.FuncMap{ + "ToCamel": helpers.ToCamel, + "ToSnake": helpers.ToSnake, + "ParamType": helpers.ParamType, + "ParamDefaultExpr": helpers.ParamDefaultExpr, + "ParamFormat": helpers.ParamFormat, + "ParamDescription": helpers.ParamDescription, + "bt": func() string { return "`" }, + }).Parse(templates.Modifier) + if err != nil { + return err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, modifier); err != nil { + return err + } + formatted, err := helpers.FormatSourceOrWarn(filePath, buf.Bytes()) + if err != nil { + return err + } + return os.WriteFile(filePath, formatted, 0644) +} + // WriteRegistry генерирует registry.go со списком всех ресурсов. -func WriteRegistry(outDir string, services []types.GenResource, subs []types.GenSubresource, actions []types.GenAction) error { +func WriteRegistry(outDir string, services []types.GenResource, subs []types.GenSubresource, actions []types.GenAction, modifiers []types.GenModifier) error { var buf bytes.Buffer buf.WriteString("package resources_gen\n\n") buf.WriteString("import \"github.com/hashicorp/terraform-plugin-framework/resource\"\n\n") @@ -152,6 +182,9 @@ func WriteRegistry(outDir string, services []types.GenResource, subs []types.Gen for _, act := range actions { buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(act.ServiceName+"_"+act.ActionName))) } + for _, modifier := range modifiers { + buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(modifier.ServiceName+"_"+modifier.ModifierName))) + } buf.WriteString("\t}\n") buf.WriteString("}\n") diff --git a/TOOLS/resource-generator/main.go b/TOOLS/resource-generator/main.go index d80c21d..6ecd13e 100644 --- a/TOOLS/resource-generator/main.go +++ b/TOOLS/resource-generator/main.go @@ -48,7 +48,7 @@ func run() error { return fmt.Errorf("NUBES_RESOURCES_GEN_DIR is required — must point to generated/{stand}/go/") } - instanceResources, subresources, actions, err := loader.LoadSpecs(resourcesDir) + instanceResources, subresources, actions, modifiers, err := loader.LoadSpecs(resourcesDir) if err != nil { return err } @@ -72,6 +72,12 @@ func run() error { writeErrs = append(writeErrs, serviceWriteErr{kind: "action", name: name, err: err}) } } + for _, modifier := range modifiers { + if err := writers.WriteModifierResource(outDir, modifier); err != nil { + name := fmt.Sprintf("%s_%s", modifier.ServiceName, modifier.ModifierName) + writeErrs = append(writeErrs, serviceWriteErr{kind: "modifier", name: name, err: err}) + } + } if len(writeErrs) > 0 { var b strings.Builder @@ -82,7 +88,7 @@ func run() error { return errors.New(strings.TrimSpace(b.String())) } - if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions); err != nil { + if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions, modifiers); err != nil { return err } diff --git a/TOOLS/yaml-generator/main.go b/TOOLS/yaml-generator/main.go index 20592c2..7ba017e 100644 --- a/TOOLS/yaml-generator/main.go +++ b/TOOLS/yaml-generator/main.go @@ -71,6 +71,16 @@ func main() { if err != nil { panic(err) } + for idx := range ops { + if strings.EqualFold(ops[idx].Action, "modify") && svc.ID == 19 { + ops[idx].Kind = "modifier" + ops[idx].Modifier = "ip_space" + } + if strings.EqualFold(ops[idx].Action, "modify") && svc.ID == 22 { + ops[idx].Kind = "modifier" + ops[idx].Modifier = "network" + } + } specYAML := types.ServiceSpec{ Name: name,