diff --git a/stackit/internal/services/mongodbflex/instance/resource.go b/stackit/internal/services/mongodbflex/instance/resource.go index b74f8e912..03e3b4d0a 100644 --- a/stackit/internal/services/mongodbflex/instance/resource.go +++ b/stackit/internal/services/mongodbflex/instance/resource.go @@ -10,6 +10,7 @@ import ( "time" mongodbflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mongodbflex/utils" + stringplanmodifierCustom "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -240,6 +241,9 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r "backup_schedule": schema.StringAttribute{ Description: descriptions["backup_schedule"], Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifierCustom.CronNormalizationModifier{}, + }, }, "flavor": schema.SingleNestedAttribute{ Required: true, @@ -852,10 +856,11 @@ func mapFields(ctx context.Context, resp *mongodbflex.InstanceResponse, model *M return fmt.Errorf("creating options: %w", core.DiagsToError(diags)) } - simplifiedModelBackupSchedule := utils.SimplifyBackupSchedule(model.BackupSchedule.ValueString()) - // If the value returned by the API is different from the one in the model after simplification, - // we update the model so that it causes an error in Terraform - if simplifiedModelBackupSchedule != types.StringPointerValue(instance.BackupSchedule).ValueString() { + // If the API returned "0 0 * * *" but user defined "00 00 * * *" in its config, + // we keep the user's "00 00 * * *" in the state to satisfy Terraform. + backupScheduleApiResp := types.StringPointerValue(instance.BackupSchedule) + if utils.SimplifyCronString(model.BackupSchedule.ValueString()) != utils.SimplifyCronString(backupScheduleApiResp.ValueString()) { + // If the API actually changed it to something else, use the API value model.BackupSchedule = types.StringPointerValue(instance.BackupSchedule) } diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index a5c5db586..12e00a332 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -534,7 +534,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int64{ - int64planmodifier.UseStateForUnknownIf(utils.Int64Changed, "metrics_retention_days", "sets `UseStateForUnknown` only if `metrics_retention_days` has not changed"), + int64planmodifier.UseStateForUnknownIf(int64planmodifier.Int64Changed, "metrics_retention_days", "sets `UseStateForUnknown` only if `metrics_retention_days` has not changed"), }, }, "metrics_retention_days_5m_downsampling": schema.Int64Attribute{ @@ -542,7 +542,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int64{ - int64planmodifier.UseStateForUnknownIf(utils.Int64Changed, "metrics_retention_days_5m_downsampling", "sets `UseStateForUnknown` only if `metrics_retention_days_5m_downsampling` has not changed"), + int64planmodifier.UseStateForUnknownIf(int64planmodifier.Int64Changed, "metrics_retention_days_5m_downsampling", "sets `UseStateForUnknown` only if `metrics_retention_days_5m_downsampling` has not changed"), }, }, "metrics_retention_days_1h_downsampling": schema.Int64Attribute{ @@ -550,7 +550,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int64{ - int64planmodifier.UseStateForUnknownIf(utils.Int64Changed, "metrics_retention_days_1h_downsampling", "sets `UseStateForUnknown` only if `metrics_retention_days_1h_downsampling` has not changed"), + int64planmodifier.UseStateForUnknownIf(int64planmodifier.Int64Changed, "metrics_retention_days_1h_downsampling", "sets `UseStateForUnknown` only if `metrics_retention_days_1h_downsampling` has not changed"), }, }, "metrics_url": schema.StringAttribute{ diff --git a/stackit/internal/services/postgresflex/instance/resource.go b/stackit/internal/services/postgresflex/instance/resource.go index 399a61189..35240bd04 100644 --- a/stackit/internal/services/postgresflex/instance/resource.go +++ b/stackit/internal/services/postgresflex/instance/resource.go @@ -19,6 +19,7 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + stringplanmodifierCustom "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/stringplanmodifier" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -209,6 +210,9 @@ func (r *instanceResource) Schema(_ context.Context, req resource.SchemaRequest, "backup_schedule": schema.StringAttribute{ Description: descriptions["backup_schedule"], Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifierCustom.CronNormalizationModifier{}, + }, }, "flavor": schema.SingleNestedAttribute{ Required: true, @@ -652,11 +656,18 @@ func mapFields(ctx context.Context, resp *postgresflex.InstanceResponse, model * return fmt.Errorf("creating storage: %w", core.DiagsToError(diags)) } + // If the API returned "0 0 * * *" but user defined "00 00 * * *" in its config, + // we keep the user's "00 00 * * *" in the state to satisfy Terraform. + backupScheduleApiResp := types.StringPointerValue(instance.BackupSchedule) + if utils.SimplifyCronString(model.BackupSchedule.ValueString()) != utils.SimplifyCronString(backupScheduleApiResp.ValueString()) { + // If the API actually changed it to something else, use the API value + model.BackupSchedule = types.StringPointerValue(instance.BackupSchedule) + } + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, instanceId) model.InstanceId = types.StringValue(instanceId) model.Name = types.StringPointerValue(instance.Name) model.ACL = aclList - model.BackupSchedule = types.StringPointerValue(instance.BackupSchedule) model.Flavor = flavorObject model.Replicas = types.Int64PointerValue(instance.Replicas) model.Storage = storageObject diff --git a/stackit/internal/services/postgresflex/instance/resource_test.go b/stackit/internal/services/postgresflex/instance/resource_test.go index 0d56ebd16..f08519f30 100644 --- a/stackit/internal/services/postgresflex/instance/resource_test.go +++ b/stackit/internal/services/postgresflex/instance/resource_test.go @@ -27,6 +27,37 @@ func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ st func TestMapFields(t *testing.T) { const testRegion = "region" + + fixtureModel := func(mods ...func(*Model)) Model { + m := Model{ + Id: types.StringValue("pid,region,iid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Name: types.StringNull(), + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringNull(), + Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{ + "id": types.StringNull(), + "description": types.StringNull(), + "cpu": types.Int64Null(), + "ram": types.Int64Null(), + }), + Replicas: types.Int64Null(), + Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{ + "class": types.StringNull(), + "size": types.Int64Null(), + }), + Version: types.StringNull(), + Region: types.StringValue(testRegion), + } + + for _, mod := range mods { + mod(&m) + } + + return m + } + tests := []struct { description string state Model @@ -49,27 +80,7 @@ func TestMapFields(t *testing.T) { &flavorModel{}, &storageModel{}, testRegion, - Model{ - Id: types.StringValue("pid,region,iid"), - InstanceId: types.StringValue("iid"), - ProjectId: types.StringValue("pid"), - Name: types.StringNull(), - ACL: types.ListNull(types.StringType), - BackupSchedule: types.StringNull(), - Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{ - "id": types.StringNull(), - "description": types.StringNull(), - "cpu": types.Int64Null(), - "ram": types.Int64Null(), - }), - Replicas: types.Int64Null(), - Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{ - "class": types.StringNull(), - "size": types.Int64Null(), - }), - Version: types.StringNull(), - Region: types.StringValue(testRegion), - }, + fixtureModel(), true, }, { @@ -262,6 +273,42 @@ func TestMapFields(t *testing.T) { }, true, }, + { + description: "backup schedule - keep state value when API strips leading zeros", + state: fixtureModel(func(m *Model) { + m.BackupSchedule = types.StringValue("00 00 * * *") + }), + input: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + BackupSchedule: utils.Ptr("0 0 * * *"), + }, + }, + flavor: &flavorModel{}, + storage: &storageModel{}, + region: testRegion, + expected: fixtureModel(func(m *Model) { + m.BackupSchedule = types.StringValue("00 00 * * *") + }), + isValid: true, + }, + { + description: "backup schedule - use updated value from API if cron actually changed", + state: fixtureModel(func(m *Model) { + m.BackupSchedule = types.StringValue("00 01 * * *") + }), + input: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + BackupSchedule: utils.Ptr("0 2 * * *"), + }, + }, + flavor: &flavorModel{}, + storage: &storageModel{}, + region: testRegion, + expected: fixtureModel(func(m *Model) { + m.BackupSchedule = types.StringValue("0 2 * * *") + }), + isValid: true, + }, { "nil_response", Model{ diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index 6f69b54da..aa0c00a3f 100644 --- a/stackit/internal/services/ske/cluster/resource.go +++ b/stackit/internal/services/ske/cluster/resource.go @@ -379,7 +379,7 @@ func (r *clusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "Full Kubernetes version used. For example, if 1.22 was set in `kubernetes_version_min`, this value may result to 1.22.15. " + SKEUpdateDoc, Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifierUtils.UseStateForUnknownIf(utils.StringChanged, "kubernetes_version_min", "sets `UseStateForUnknown` only if `kubernetes_min_version` has not changed"), + stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringChanged, "kubernetes_version_min", "sets `UseStateForUnknown` only if `kubernetes_min_version` has not changed"), }, }, "egress_address_ranges": schema.ListAttribute{ @@ -462,7 +462,7 @@ func (r *clusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "Full OS image version used. For example, if 3815.2 was set in `os_version_min`, this value may result to 3815.2.2. " + SKEUpdateDoc, Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifierUtils.UseStateForUnknownIf(utils.StringChanged, "os_version_min", "sets `UseStateForUnknown` only if `os_version_min` has not changed"), + stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringChanged, "os_version_min", "sets `UseStateForUnknown` only if `os_version_min` has not changed"), }, }, "volume_type": schema.StringAttribute{ diff --git a/stackit/internal/services/sqlserverflex/instance/resource.go b/stackit/internal/services/sqlserverflex/instance/resource.go index 2ca1cb8f4..8956b160d 100644 --- a/stackit/internal/services/sqlserverflex/instance/resource.go +++ b/stackit/internal/services/sqlserverflex/instance/resource.go @@ -10,6 +10,7 @@ import ( "time" sqlserverflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/sqlserverflex/utils" + stringplanmodifierCustom "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -230,9 +231,8 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r "backup_schedule": schema.StringAttribute{ Description: descriptions["backup_schedule"], Optional: true, - Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), + stringplanmodifierCustom.CronNormalizationModifier{}, }, }, "flavor": schema.SingleNestedAttribute{ @@ -784,10 +784,11 @@ func mapFields(ctx context.Context, resp *sqlserverflex.GetInstanceResponse, mod return fmt.Errorf("creating options: %w", core.DiagsToError(diags)) } - simplifiedModelBackupSchedule := utils.SimplifyBackupSchedule(model.BackupSchedule.ValueString()) - // If the value returned by the API is different from the one in the model after simplification, - // we update the model so that it causes an error in Terraform - if simplifiedModelBackupSchedule != types.StringPointerValue(instance.BackupSchedule).ValueString() { + // If the API returned "0 0 * * *" but user defined "00 00 * * *" in its config, + // we keep the user's "00 00 * * *" in the state to satisfy Terraform. + backupScheduleApiResp := types.StringPointerValue(instance.BackupSchedule) + if utils.SimplifyCronString(model.BackupSchedule.ValueString()) != utils.SimplifyCronString(backupScheduleApiResp.ValueString()) { + // If the API actually changed it to something else, use the API value model.BackupSchedule = types.StringPointerValue(instance.BackupSchedule) } diff --git a/stackit/internal/utils/attributes.go b/stackit/internal/utils/attributes.go index ae38c2af3..bddc30baa 100644 --- a/stackit/internal/utils/attributes.go +++ b/stackit/internal/utils/attributes.go @@ -7,11 +7,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/int64planmodifier" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/stringplanmodifier" ) type attributeGetter interface { @@ -47,51 +44,3 @@ func GetTimeFromStringAttribute(ctx context.Context, attributePath path.Path, so return diags } - -// Int64Changed sets UseStateForUnkown to true if the attribute's planned value matches the current state -func Int64Changed(ctx context.Context, attributeName string, request planmodifier.Int64Request, response *int64planmodifier.UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform - dependencyPath := request.Path.ParentPath().AtName(attributeName) - - var attributePlan types.Int64 - diags := request.Plan.GetAttribute(ctx, dependencyPath, &attributePlan) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - var attributeState types.Int64 - diags = request.State.GetAttribute(ctx, dependencyPath, &attributeState) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - if attributeState == attributePlan { - response.UseStateForUnknown = true - return - } -} - -// StringChanged sets UseStateForUnkown to true if the attribute's planned value matches the current state -func StringChanged(ctx context.Context, attributeName string, request planmodifier.StringRequest, response *stringplanmodifier.UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform - dependencyPath := request.Path.ParentPath().AtName(attributeName) - - var attributePlan types.String - diags := request.Plan.GetAttribute(ctx, dependencyPath, &attributePlan) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - var attributeState types.String - diags = request.State.GetAttribute(ctx, dependencyPath, &attributeState) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - if attributeState == attributePlan { - response.UseStateForUnknown = true - return - } -} diff --git a/stackit/internal/utils/cron.go b/stackit/internal/utils/cron.go new file mode 100644 index 000000000..78ef0ae31 --- /dev/null +++ b/stackit/internal/utils/cron.go @@ -0,0 +1,24 @@ +package utils + +import ( + "regexp" + "strings" +) + +// SimplifyCronString removes leading 0s from backup schedule numbers (e.g. "00 00 * * *" becomes "0 0 * * *") +// Needed as some API might do it internally and would otherwise cause inconsistent result in Terraform +func SimplifyCronString(cron string) string { + regex := regexp.MustCompile(`0+\d+`) // Matches series of one or more zeros followed by a series of one or more digits + simplifiedCron := regex.ReplaceAllStringFunc(cron, func(match string) string { + simplified := strings.TrimLeft(match, "0") + if simplified == "" { + simplified = "0" + } + return simplified + }) + + whiteSpaceRegex := regexp.MustCompile(`\s+`) + simplifiedCron = whiteSpaceRegex.ReplaceAllString(simplifiedCron, " ") + + return simplifiedCron +} diff --git a/stackit/internal/utils/cron_test.go b/stackit/internal/utils/cron_test.go new file mode 100644 index 000000000..78d44833d --- /dev/null +++ b/stackit/internal/utils/cron_test.go @@ -0,0 +1,82 @@ +package utils + +import ( + "testing" +) + +func TestSimplifyCronString(t *testing.T) { + tests := []struct { + description string + input string + expected string + }{ + { + "simple schedule", + "0 0 * * *", + "0 0 * * *", + }, + { + "schedule with leading zeros", + "00 00 * * *", + "0 0 * * *", + }, + { + "schedule with leading zeros 2", + "00 001 * * *", + "0 1 * * *", + }, + { + "schedule with leading zeros 3", + "00 0010 * * *", + "0 10 * * *", + }, + { + "simple schedule with slash", + "0 0/6 * * *", + "0 0/6 * * *", + }, + { + "schedule with leading zeros and slash", + "00 00/6 * * *", + "0 0/6 * * *", + }, + { + "schedule with leading zeros and slash 2", + "00 001/06 * * *", + "0 1/6 * * *", + }, + { + "simple schedule with comma", + "0 10,15 * * *", + "0 10,15 * * *", + }, + { + "schedule with leading zeros and comma", + "0 010,0015 * * *", + "0 10,15 * * *", + }, + { + "simple schedule with comma and slash", + "0 0-11/10 * * *", + "0 0-11/10 * * *", + }, + { + "schedule with leading zeros, comma, and slash", + "00 000-011/010 * * *", + "0 0-11/10 * * *", + }, + { + "schedule with multiple spaces", + "00 01 * * *", + "0 1 * * *", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := SimplifyCronString(tt.input) + if output != tt.expected { + t.Fatalf("Data does not match: %s", output) + } + }) + } +} diff --git a/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go b/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go index 96e5f1e0e..d8ea31d7c 100644 --- a/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go +++ b/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go @@ -5,6 +5,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" ) type UseStateForUnknownFuncResponse struct { @@ -69,3 +70,27 @@ func (m useStateForUnknownIf) PlanModifyInt64(ctx context.Context, req planmodif resp.PlanValue = req.StateValue } } + +// Int64Changed sets UseStateForUnkown to true if the attribute's planned value matches the current state +func Int64Changed(ctx context.Context, attributeName string, request planmodifier.Int64Request, response *UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform + dependencyPath := request.Path.ParentPath().AtName(attributeName) + + var attributePlan types.Int64 + diags := request.Plan.GetAttribute(ctx, dependencyPath, &attributePlan) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + var attributeState types.Int64 + diags = request.State.GetAttribute(ctx, dependencyPath, &attributeState) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + if attributeState == attributePlan { + response.UseStateForUnknown = true + return + } +} diff --git a/stackit/internal/utils/planmodifiers/stringplanmodifier/cron.go b/stackit/internal/utils/planmodifiers/stringplanmodifier/cron.go new file mode 100644 index 000000000..cca762a8c --- /dev/null +++ b/stackit/internal/utils/planmodifiers/stringplanmodifier/cron.go @@ -0,0 +1,32 @@ +package stringplanmodifier + +import ( + "context" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +type CronNormalizationModifier struct{} + +func (m CronNormalizationModifier) Description(_ context.Context) string { + return "Prevents drift when the API normalizes cron strings (e.g., removing leading zeros)." +} + +func (m CronNormalizationModifier) MarkdownDescription(_ context.Context) string { + return "Prevents drift when the API normalizes cron strings (e.g., removing leading zeros)." +} + +func (m CronNormalizationModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { // nolint:gocritic // function signature required by Terraform + if req.ConfigValue.IsNull() || req.StateValue.IsNull() { + return + } + + requestValueNormalized := utils.SimplifyCronString(req.ConfigValue.ValueString()) + stateValueNormalized := utils.SimplifyCronString(req.StateValue.ValueString()) + + if requestValueNormalized == stateValueNormalized { + resp.PlanValue = req.StateValue + } +} diff --git a/stackit/internal/utils/planmodifiers/stringplanmodifier/cron_test.go b/stackit/internal/utils/planmodifiers/stringplanmodifier/cron_test.go new file mode 100644 index 000000000..d99e96870 --- /dev/null +++ b/stackit/internal/utils/planmodifiers/stringplanmodifier/cron_test.go @@ -0,0 +1,73 @@ +package stringplanmodifier + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestCronNormalizationModifier(t *testing.T) { + modifier := CronNormalizationModifier{} + + tests := []struct { + name string + configValue string + stateValue string + expectSetToState bool // If true, we expect PlanValue to be forced to StateValue + }{ + { + name: "exact match", + configValue: "0 0 * * *", + stateValue: "0 0 * * *", + expectSetToState: true, + }, + { + name: "normalized match (leading zeros)", + configValue: "00 00 * * *", + stateValue: "0 0 * * *", + expectSetToState: true, + }, + { + name: "normalized match (spacing)", + configValue: "0 0 * * *", + stateValue: "0 0 * * *", + expectSetToState: true, + }, + { + name: "actual difference", + configValue: "0 1 * * *", + stateValue: "0 0 * * *", + expectSetToState: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + req := planmodifier.StringRequest{ + ConfigValue: types.StringValue(tt.configValue), + StateValue: types.StringValue(tt.stateValue), + } + resp := planmodifier.StringResponse{ + PlanValue: types.StringValue(tt.configValue), // Default behavior: Plan follows Config + } + + modifier.PlanModifyString(ctx, req, &resp) + + if tt.expectSetToState { + if !resp.PlanValue.Equal(types.StringValue(tt.stateValue)) { + t.Errorf("Expected PlanValue to be overwritten by StateValue (%s), but got %s", + tt.stateValue, resp.PlanValue.ValueString()) + } + } else { + if !resp.PlanValue.Equal(types.StringValue(tt.configValue)) { + t.Errorf("Expected PlanValue to remain as ConfigValue (%s), but got %s", + tt.configValue, resp.PlanValue.ValueString()) + } + } + }) + } +} diff --git a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go index 40f1d8aa6..7b5568702 100644 --- a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go +++ b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go @@ -5,6 +5,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" ) type UseStateForUnknownFuncResponse struct { @@ -69,3 +70,27 @@ func (m useStateForUnknownIf) PlanModifyString(ctx context.Context, req planmodi resp.PlanValue = req.StateValue } } + +// StringChanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func StringChanged(ctx context.Context, attributeName string, request planmodifier.StringRequest, response *UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform + dependencyPath := request.Path.ParentPath().AtName(attributeName) + + var attributePlan types.String + diags := request.Plan.GetAttribute(ctx, dependencyPath, &attributePlan) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + var attributeState types.String + diags = request.State.GetAttribute(ctx, dependencyPath, &attributeState) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + if attributeState == attributePlan { + response.UseStateForUnknown = true + return + } +} diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index c19acafb8..36e1a903c 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -86,20 +85,6 @@ func ListValuetoStringSlice(list basetypes.ListValue) ([]string, error) { return result, nil } -// SimplifyBackupSchedule removes leading 0s from backup schedule numbers (e.g. "00 00 * * *" becomes "0 0 * * *") -// Needed as the API does it internally and would otherwise cause inconsistent result in Terraform -func SimplifyBackupSchedule(schedule string) string { - regex := regexp.MustCompile(`0+\d+`) // Matches series of one or more zeros followed by a series of one or more digits - simplifiedSchedule := regex.ReplaceAllStringFunc(schedule, func(match string) string { - simplified := strings.TrimLeft(match, "0") - if simplified == "" { - simplified = "0" - } - return simplified - }) - return simplifiedSchedule -} - // ConvertPointerSliceToStringSlice safely converts a slice of string pointers to a slice of strings. func ConvertPointerSliceToStringSlice(pointerSlice []*string) []string { if pointerSlice == nil { diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index 48287d800..f069c5c35 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -183,78 +183,6 @@ func TestConvertPointerSliceToStringSlice(t *testing.T) { } } -func TestSimplifyBackupSchedule(t *testing.T) { - tests := []struct { - description string - input string - expected string - }{ - { - "simple schedule", - "0 0 * * *", - "0 0 * * *", - }, - { - "schedule with leading zeros", - "00 00 * * *", - "0 0 * * *", - }, - { - "schedule with leading zeros 2", - "00 001 * * *", - "0 1 * * *", - }, - { - "schedule with leading zeros 3", - "00 0010 * * *", - "0 10 * * *", - }, - { - "simple schedule with slash", - "0 0/6 * * *", - "0 0/6 * * *", - }, - { - "schedule with leading zeros and slash", - "00 00/6 * * *", - "0 0/6 * * *", - }, - { - "schedule with leading zeros and slash 2", - "00 001/06 * * *", - "0 1/6 * * *", - }, - { - "simple schedule with comma", - "0 10,15 * * *", - "0 10,15 * * *", - }, - { - "schedule with leading zeros and comma", - "0 010,0015 * * *", - "0 10,15 * * *", - }, - { - "simple schedule with comma and slash", - "0 0-11/10 * * *", - "0 0-11/10 * * *", - }, - { - "schedule with leading zeros, comma, and slash", - "00 000-011/010 * * *", - "0 0-11/10 * * *", - }, - } - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - output := SimplifyBackupSchedule(tt.input) - if output != tt.expected { - t.Fatalf("Data does not match: %s", output) - } - }) - } -} - func TestIsLegacyProjectRole(t *testing.T) { tests := []struct { description string