diff --git a/TOOLS/resource-generator/internal/templates/action.go b/TOOLS/resource-generator/internal/templates/action.go new file mode 100644 index 0000000..5e099ff --- /dev/null +++ b/TOOLS/resource-generator/internal/templates/action.go @@ -0,0 +1,176 @@ +package templates + +const Action = `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 +} +` diff --git a/TOOLS/resource-generator/internal/templates/instance.go b/TOOLS/resource-generator/internal/templates/instance.go new file mode 100644 index 0000000..3e7d521 --- /dev/null +++ b/TOOLS/resource-generator/internal/templates/instance.go @@ -0,0 +1,610 @@ +package templates + +const Instance = `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 +} +` + diff --git a/TOOLS/resource-generator/internal/templates/subresource.go b/TOOLS/resource-generator/internal/templates/subresource.go new file mode 100644 index 0000000..1f90258 --- /dev/null +++ b/TOOLS/resource-generator/internal/templates/subresource.go @@ -0,0 +1,442 @@ +package templates + +const Subresource = `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(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 .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 found || !known { + plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) + resp.Diagnostics.AddWarning("Подресурс уже существует", "Операция вернула duplicate/exist, объект принят в state") + 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 { + resp.Diagnostics.AddWarning("Подресурс не найден", "Объект уже удалён, пропускаем: "+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 +} +` + diff --git a/TOOLS/resource-generator/internal/templates/templates.go b/TOOLS/resource-generator/internal/templates/templates.go index 49d0136..d13bc89 100644 --- a/TOOLS/resource-generator/internal/templates/templates.go +++ b/TOOLS/resource-generator/internal/templates/templates.go @@ -1,1224 +1,3 @@ +// Package templates — шаблоны генерации Go-кода ресурсов. +// Разнесены по файлам: instance.go, subresource.go, action.go. package templates - -const Instance = `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 Subresource = `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(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 .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 found || !known { - plan.ID = types.StringValue(resources_core.BuildSubresourceID(instanceUID, "{{.SubName}}", idParams)) - resp.Diagnostics.AddWarning("Подресурс уже существует", "Операция вернула duplicate/exist, объект принят в state") - 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 { - resp.Diagnostics.AddWarning("Подресурс не найден", "Объект уже удалён, пропускаем: "+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 Action = `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 -} -`