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 cb609196f..762ab3637 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -533,7 +533,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{ @@ -541,7 +541,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{ @@ -549,7 +549,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 49532fa64..75b0d195e 100644 --- a/stackit/internal/services/postgresflex/instance/resource_test.go +++ b/stackit/internal/services/postgresflex/instance/resource_test.go @@ -26,6 +26,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 @@ -48,27 +79,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, }, { @@ -261,6 +272,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: new("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: new("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/postgresflex/postgresflex_acc_test.go b/stackit/internal/services/postgresflex/postgresflex_acc_test.go index 122633b36..ccc0b6782 100644 --- a/stackit/internal/services/postgresflex/postgresflex_acc_test.go +++ b/stackit/internal/services/postgresflex/postgresflex_acc_test.go @@ -9,13 +9,14 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" - "github.com/stackitcloud/stackit-sdk-go/core/utils" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/wait" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" ) // Instance resource data @@ -23,7 +24,7 @@ var instanceResource = map[string]string{ "project_id": testutil.ProjectId, "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum)), "acl": "192.168.0.0/16", - "backup_schedule": "00 16 * * *", + "backup_schedule": "00 16 * * *", // ensure it works properly with leading zeros "backup_schedule_updated": "00 12 * * *", "flavor_cpu": "2", "flavor_ram": "4", @@ -202,7 +203,7 @@ func TestAccPostgresFlexFlexResource(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.#", "1"), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.0", instanceResource["acl"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "backup_schedule", instanceResource["backup_schedule"]), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "backup_schedule", "0 16 * * *"), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.id", instanceResource["flavor_id"]), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.description", instanceResource["flavor_description"]), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.cpu", instanceResource["flavor_cpu"]), @@ -248,7 +249,18 @@ func TestAccPostgresFlexFlexResource(t *testing.T) { }, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"password"}, + ImportStateVerifyIgnore: []string{"password", "backup_schedule"}, + ImportStateCheck: func(s []*terraform.InstanceState) error { + if len(s) != 1 { + return fmt.Errorf("expected 1 state, got %d", len(s)) + } + + if utils.SimplifyCronString(s[0].Attributes["backup_schedule"]) != utils.SimplifyCronString(instanceResource["backup_schedule"]) { + return fmt.Errorf("expected backup_schedule %s, got %s", instanceResource["backup_schedule"], s[0].Attributes["backup_schedule"]) + } + + return nil + }, }, { ResourceName: "stackit_postgresflex_user.user", @@ -354,7 +366,7 @@ func testAccCheckPostgresFlexDestroy(s *terraform.State) error { if items[i].Id == nil { continue } - if utils.Contains(instancesToDestroy, *items[i].Id) { + if sdkUtils.Contains(instancesToDestroy, *items[i].Id) { err := client.ForceDeleteInstanceExecute(ctx, testutil.ProjectId, testutil.Region, *items[i].Id) if err != nil { return fmt.Errorf("deleting instance %s during CheckDestroy: %w", *items[i].Id, err) diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index 69b916fbc..914804ab5 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 d45cdc19c..8fc6ca839 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" @@ -231,6 +232,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ + stringplanmodifierCustom.CronNormalizationModifier{}, stringplanmodifier.UseStateForUnknown(), }, }, @@ -783,10 +785,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/services/sqlserverflex/sqlserverflex_acc_test.go b/stackit/internal/services/sqlserverflex/sqlserverflex_acc_test.go index e88ac5994..a125ba8aa 100644 --- a/stackit/internal/services/sqlserverflex/sqlserverflex_acc_test.go +++ b/stackit/internal/services/sqlserverflex/sqlserverflex_acc_test.go @@ -31,7 +31,7 @@ var testConfigVarsMin = config.Variables{ "name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), "flavor_cpu": config.IntegerVariable(4), "flavor_ram": config.IntegerVariable(16), - "flavor_description": config.StringVariable("SQLServer-Flex-4.16-Standard-EU01"), + "flavor_description": config.StringVariable("SQLServer-Flex-4.16-Single-Standard-EU01"), "replicas": config.IntegerVariable(1), "flavor_id": config.StringVariable("4.16-Single"), "username": config.StringVariable(fmt.Sprintf("tf-acc-user-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlpha))), diff --git a/stackit/internal/utils/attributes.go b/stackit/internal/utils/attributes.go index f9af5930f..a8ac299a2 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 856460f05..365586736 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -182,78 +182,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