diff --git a/stackit/internal/services/postgresflexalpha/instance/resource.go b/stackit/internal/services/postgresflexalpha/instance/resource.go index 92877870..52742195 100644 --- a/stackit/internal/services/postgresflexalpha/instance/resource.go +++ b/stackit/internal/services/postgresflexalpha/instance/resource.go @@ -60,14 +60,6 @@ type flavorModel struct { RAM types.Int64 `tfsdk:"ram"` } -//// Types corresponding to flavorModel -//var flavorTypes = map[string]attr.Type{ -// "id": basetypes.StringType{}, -// "description": basetypes.StringType{}, -// "cpu": basetypes.Int64Type{}, -// "ram": basetypes.Int64Type{}, -//} - func (r *instanceResource) ValidateConfig( ctx context.Context, req resource.ValidateConfigRequest, diff --git a/stackit/internal/services/sqlserverflexbeta/instance/flavor_functions.go b/stackit/internal/services/sqlserverflexbeta/instance/flavor_functions.go new file mode 100644 index 00000000..7b856dbf --- /dev/null +++ b/stackit/internal/services/sqlserverflexbeta/instance/flavor_functions.go @@ -0,0 +1,65 @@ +package sqlserverflexbeta + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/v3beta1api" +) + +type flavorsClientReader interface { + GetFlavorsRequest( + ctx context.Context, + projectId, region string, + ) v3beta1api.ApiGetFlavorsRequestRequest +} + +func getAllFlavors(ctx context.Context, client flavorsClientReader, projectId, region string) ( + []v3beta1api.ListFlavors, + error, +) { + getAllFilter := func(_ v3beta1api.ListFlavors) bool { return true } + flavorList, err := getFlavorsByFilter(ctx, client, projectId, region, getAllFilter) + if err != nil { + return nil, err + } + return flavorList, nil +} + +// getFlavorsByFilter is a helper function to retrieve flavors using a filtern function. +// Hint: The API does not have a GetFlavors endpoint, only ListFlavors +func getFlavorsByFilter( + ctx context.Context, + client flavorsClientReader, + projectId, region string, + filter func(db v3beta1api.ListFlavors) bool, +) ([]v3beta1api.ListFlavors, error) { + if projectId == "" || region == "" { + return nil, fmt.Errorf("listing v3beta1api flavors: projectId and region are required") + } + + const pageSize = 25 + + var result = make([]v3beta1api.ListFlavors, 0) + + for page := int64(1); ; page++ { + res, err := client.GetFlavorsRequest(ctx, projectId, region). + Page(page).Size(pageSize).Sort(v3beta1api.FLAVORSORT_INDEX_ASC).Execute() + if err != nil { + return nil, fmt.Errorf("requesting flavors list (page %d): %w", page, err) + } + + // If the API returns no flavors, we have reached the end of the list. + if len(res.Flavors) == 0 { + break + } + + for _, flavor := range res.Flavors { + if filter(flavor) { + result = append(result, flavor) + } + } + } + + return result, nil +} diff --git a/stackit/internal/services/sqlserverflexbeta/instance/functions.go b/stackit/internal/services/sqlserverflexbeta/instance/functions.go index b079d741..1674e0e0 100644 --- a/stackit/internal/services/sqlserverflexbeta/instance/functions.go +++ b/stackit/internal/services/sqlserverflexbeta/instance/functions.go @@ -22,7 +22,7 @@ import ( func mapResponseToModel( ctx context.Context, resp *v3beta1api.GetInstanceResponse, - m *sqlserverflexbetaResGen.InstanceModel, + m *LocalInstanceModel, tfDiags diag.Diagnostics, ) error { m.BackupSchedule = types.StringValue(resp.GetBackupSchedule()) @@ -133,7 +133,7 @@ func mapDataResponseToModel( func handleEncryption( ctx context.Context, - m *sqlserverflexbetaResGen.InstanceModel, + m *LocalInstanceModel, resp *v3beta1api.GetInstanceResponse, ) sqlserverflexbetaResGen.EncryptionValue { if !resp.HasEncryption() || @@ -191,7 +191,7 @@ func handleDSEncryption( func toCreatePayload( ctx context.Context, - model *sqlserverflexbetaResGen.InstanceModel, + model *LocalInstanceModel, ) (*v3beta1api.CreateInstanceRequestPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") @@ -241,7 +241,7 @@ func toCreatePayload( func toUpdatePayload( ctx context.Context, - m *sqlserverflexbetaResGen.InstanceModel, + m *LocalInstanceModel, resp *resource.UpdateResponse, ) (*v3beta1api.UpdateInstanceRequestPayload, error) { if m == nil { diff --git a/stackit/internal/services/sqlserverflexbeta/instance/resource.go b/stackit/internal/services/sqlserverflexbeta/instance/resource.go index bb7e67b4..e61091cf 100644 --- a/stackit/internal/services/sqlserverflexbeta/instance/resource.go +++ b/stackit/internal/services/sqlserverflexbeta/instance/resource.go @@ -10,7 +10,10 @@ import ( "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/planmodifier" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" @@ -42,8 +45,19 @@ type instanceResource struct { providerData core.ProviderData } -// resourceModel describes the resource data model. -type resourceModel = sqlserverflexbetaResGen.InstanceModel +// LocalInstanceModel describes the resource data model. +type LocalInstanceModel struct { + sqlserverflexbetaResGen.InstanceModel + Flavor types.Object `tfsdk:"flavor"` +} + +// LocalFlavorModel Struct corresponding to Model.Flavor +type LocalFlavorModel struct { + Id types.String `tfsdk:"id"` + Description types.String `tfsdk:"description"` + CPU types.Int64 `tfsdk:"cpu"` + RAM types.Int64 `tfsdk:"ram"` +} func (r *instanceResource) Metadata( _ context.Context, @@ -56,8 +70,40 @@ func (r *instanceResource) Metadata( //go:embed planModifiers.yaml var modifiersFileByte []byte -func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *instanceResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { s := sqlserverflexbetaResGen.InstanceResourceSchema(ctx) + s.Attributes["flavor"] = schema.SingleNestedAttribute{ + Optional: true, + DeprecationMessage: "Please use flavor_id instead.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + UseStateForUnknownIfFlavorUnchanged(req), + }, + }, + "description": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + UseStateForUnknownIfFlavorUnchanged(req), + }, + }, + "cpu": schema.Int64Attribute{ + DeprecationMessage: "Please use flavor_id instead.", + Optional: true, + }, + "ram": schema.Int64Attribute{ + DeprecationMessage: "Please use flavor_id instead.", + Optional: true, + }, + }, + } + + s.Attributes["flavor_id"] = schema.StringAttribute{ + Optional: true, + Description: "The id of the instance flavor.", + MarkdownDescription: "The id of the instance flavor.", + } fields, err := utils.ReadModifiersConfig(modifiersFileByte) if err != nil { @@ -123,7 +169,7 @@ func (r *instanceResource) ModifyPlan( if req.Config.Raw.IsNull() { return } - var configModel resourceModel + var configModel LocalInstanceModel resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) if resp.Diagnostics.HasError() { return @@ -132,7 +178,7 @@ func (r *instanceResource) ModifyPlan( if req.Plan.Raw.IsNull() { return } - var planModel resourceModel + var planModel LocalInstanceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) if resp.Diagnostics.HasError() { return @@ -150,7 +196,7 @@ func (r *instanceResource) ModifyPlan( } func (r *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data resourceModel + var data LocalInstanceModel crateErr := "[SQL Server Flex BETA - Create] error" // Read Terraform plan data into the model @@ -167,6 +213,73 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques ctx = tflog.SetField(ctx, "project_id", projectID) ctx = tflog.SetField(ctx, "region", region) + // determine flavor ID + var flModel = &LocalFlavorModel{} + if !(data.Flavor.IsNull() || data.Flavor.IsUnknown()) { + diags := data.Flavor.As(ctx, flModel, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + flavors, err := getAllFlavors(ctx, r.client.DefaultAPI, projectID, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading flavors", fmt.Sprintf("getAllFlavors: %v", err)) + return + } + tflog.Debug(ctx, fmt.Sprintf("loaded flavors: %d", len(flavors))) + + var foundFlavors []v3beta1api.ListFlavors + for _, flavor := range flavors { + if flModel.CPU.ValueInt64() != int64(flavor.Cpu) { + // tflog.Debug(ctx, fmt.Sprintf("flavor - cpu did not match (%d - %d)", flModel.CPU.ValueInt64(), flavor.Cpu)) + continue + } + if flModel.RAM.ValueInt64() != int64(flavor.Memory) { + // tflog.Debug(ctx, fmt.Sprintf("flavor - ram did not match (%d - %d)", flModel.RAM.ValueInt64(), flavor.Memory)) + continue + } + tmpNodeType := "Single" + if data.Replicas.ValueInt64() > 1 { + tmpNodeType = "Replica" + } + if strings.ToLower(tmpNodeType) != strings.ToLower(flavor.NodeType) { + //tflog.Debug( + // ctx, + // fmt.Sprintf( + // "flavor - nodeType did not match ('%s' - '%s')", + // strings.ToLower(tmpNodeType), + // strings.ToLower(flavor.NodeType), + // ), + //) + continue + } + tflog.Debug(ctx, fmt.Sprintf("found flavor %s, checking storage classes", flavor.Id)) + for _, sc := range flavor.StorageClasses { + if data.Storage.Class.ValueString() != sc.Class { + continue + } + tflog.Debug(ctx, fmt.Sprintf("found storage class '%s' for flavor '%s', checking storage classes", sc.Class, flavor.Id)) + foundFlavors = append(foundFlavors, flavor) + } + } + if len(foundFlavors) == 0 { + resp.Diagnostics.AddError("get flavor", "could not find requested flavor") + return + } + if len(foundFlavors) > 1 { + resp.Diagnostics.AddError("get flavor", "found too many matching flavors") + return + } + + f := foundFlavors[0] + flModel.Description = types.StringValue(f.Description) + flModel.Id = utils.BuildInternalTerraformId(data.ProjectId.ValueString(), region, f.Id) + data.FlavorId = types.StringValue(f.Id) + //flModel. .MaxGb = types.Int32Value(f.MaxGB) + //flModel.MinGb = types.Int32Value(f.MinGB) + } + // Generate API request body from model payload, err := toCreatePayload(ctx, &data) if err != nil { @@ -256,7 +369,7 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques } func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data resourceModel + var data LocalInstanceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) @@ -309,7 +422,7 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r } func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data resourceModel + var data LocalInstanceModel updateInstanceError := "Error updating instance" resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) @@ -389,7 +502,7 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques } func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data resourceModel + var data LocalInstanceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) diff --git a/stackit/internal/services/sqlserverflexbeta/instance/use_state_for_unknown_if_flavor_unchanged_modifier.go b/stackit/internal/services/sqlserverflexbeta/instance/use_state_for_unknown_if_flavor_unchanged_modifier.go new file mode 100644 index 00000000..c944f921 --- /dev/null +++ b/stackit/internal/services/sqlserverflexbeta/instance/use_state_for_unknown_if_flavor_unchanged_modifier.go @@ -0,0 +1,85 @@ +package sqlserverflexbeta + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +type useStateForUnknownIfFlavorUnchangedModifier struct { + Req resource.SchemaRequest +} + +// UseStateForUnknownIfFlavorUnchanged returns a plan modifier similar to UseStateForUnknown +// if the RAM and CPU values are not changed in the plan. Otherwise, the plan modifier does nothing. +func UseStateForUnknownIfFlavorUnchanged(req resource.SchemaRequest) planmodifier.String { + return useStateForUnknownIfFlavorUnchangedModifier{ + Req: req, + } +} + +func (m useStateForUnknownIfFlavorUnchangedModifier) Description(context.Context) string { + return "UseStateForUnknownIfFlavorUnchanged returns a plan modifier similar to UseStateForUnknown if the RAM and CPU values are not changed in the plan. Otherwise, the plan modifier does nothing." +} + +func (m useStateForUnknownIfFlavorUnchangedModifier) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +func (m useStateForUnknownIfFlavorUnchangedModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { // nolint:gocritic // function signature required by Terraform + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + // The above checks are taken from the UseStateForUnknown plan modifier implementation + // (https://github.com/hashicorp/terraform-plugin-framework/blob/main/resource/schema/stringplanmodifier/use_state_for_unknown.go#L38) + + var stateModel LocalInstanceModel + diags := req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var stateFlavor = &LocalFlavorModel{} + if !(stateModel.Flavor.IsNull() || stateModel.Flavor.IsUnknown()) { + diags = stateModel.Flavor.As(ctx, stateFlavor, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + var planModel LocalInstanceModel + diags = req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var planFlavor = &LocalFlavorModel{} + if !(planModel.Flavor.IsNull() || planModel.Flavor.IsUnknown()) { + diags = planModel.Flavor.As(ctx, planFlavor, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + if planFlavor.CPU == stateFlavor.CPU && planFlavor.RAM == stateFlavor.RAM { + resp.PlanValue = req.StateValue + } +}