From f887fb8b3fa42ea6f18935f56692d02493109d2f Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Fri, 21 Aug 2026 17:26:01 +0200 Subject: [PATCH 1/2] refac: use UnchangedPaths modifier --- .../services/sfs/resourcepool/resource.go | 5 +++- .../internal/services/ske/cluster/resource.go | 10 +++++-- stackit/internal/services/ske/utils/util.go | 27 ------------------- .../use_state_for_unknown_if.go | 25 ----------------- 4 files changed, 12 insertions(+), 55 deletions(-) diff --git a/stackit/internal/services/sfs/resourcepool/resource.go b/stackit/internal/services/sfs/resourcepool/resource.go index 16fdf3579..096510ceb 100644 --- a/stackit/internal/services/sfs/resourcepool/resource.go +++ b/stackit/internal/services/sfs/resourcepool/resource.go @@ -237,7 +237,10 @@ func (r *resourcePoolResource) Schema(_ context.Context, _ resource.SchemaReques Description: "Name of the snapshot policy.", Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringUnchanged(path.Root("snapshot_policy").AtName("id")), "sets `UseStateForUnknown` only if `id` has not changed"), + stringplanmodifierUtils.UseStateForUnknownIf( + stringplanmodifierUtils.UnchangedPaths(path.MatchRoot("snapshot_policy").AtName("id")), + "sets `UseStateForUnknown` only if `id` has not changed", + ), }, }, }, diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index 2fd10bee1..393c4c50f 100644 --- a/stackit/internal/services/ske/cluster/resource.go +++ b/stackit/internal/services/ske/cluster/resource.go @@ -489,7 +489,10 @@ 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(stringplanmodifierUtils.StringUnchanged(path.Root("kubernetes_version_min")), "sets `UseStateForUnknown` only if `kubernetes_min_version` has not changed"), + stringplanmodifierUtils.UseStateForUnknownIf( + stringplanmodifierUtils.UnchangedPaths(path.MatchRoot("kubernetes_version_min")), + "sets `UseStateForUnknown` only if `kubernetes_min_version` has not changed", + ), }, }, "egress_address_ranges": schema.ListAttribute{ @@ -580,7 +583,10 @@ 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(skeUtils.HasOsVersionMinChanged, "sets `UseStateForUnknown` only if `os_version_min` has not changed"), //nolint:staticcheck // temporary fix for issue with StringUnchanged + stringplanmodifierUtils.UseStateForUnknownIf( + stringplanmodifierUtils.UnchangedPaths(path.MatchRelative().AtParent().AtName("os_version_min")), + "sets `UseStateForUnknown` only if `os_version_min` has not changed", + ), }, }, "volume_type": schema.StringAttribute{ diff --git a/stackit/internal/services/ske/utils/util.go b/stackit/internal/services/ske/utils/util.go index 6b4aca343..ef4581caa 100644 --- a/stackit/internal/services/ske/utils/util.go +++ b/stackit/internal/services/ske/utils/util.go @@ -5,14 +5,11 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/stackit-sdk-go/core/config" ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/stringplanmodifier" ) func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *ske.APIClient { @@ -45,27 +42,3 @@ func IsEmptyExtension(extension *ske.Extension) bool { } return false } - -// Deprecated: HasOsVersionMinChanged -func HasOsVersionMinChanged(ctx context.Context, request planmodifier.StringRequest, response *stringplanmodifier.UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform - dependencyPath := request.Path.ParentPath().AtName("os_version_min") - - var minVersionPlan types.String - diags := request.Plan.GetAttribute(ctx, dependencyPath, &minVersionPlan) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - var minVersionState types.String - diags = request.State.GetAttribute(ctx, dependencyPath, &minVersionState) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - if minVersionState == minVersionPlan { - response.UseStateForUnknown = true - return - } -} 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 08847b46d..30c39f358 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 @@ -7,7 +7,6 @@ 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" ) type UseStateForUnknownFuncResponse struct { @@ -71,30 +70,6 @@ func (m useStateForUnknownIf) PlanModifyString(ctx context.Context, req planmodi } } -// StringUnchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state -func StringUnchanged(attributePath path.Path) UseStateForUnknownIfFunc { // nolint:gocritic // function signature required by Terraform - return func(ctx context.Context, request planmodifier.StringRequest, response *UseStateForUnknownFuncResponse) { - var attributePlan types.String - diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - var attributeState types.String - diags = request.State.GetAttribute(ctx, attributePath, &attributeState) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - if attributeState == attributePlan { - response.UseStateForUnknown = true - return - } - } -} - // UnchangedPaths sets UseStateForUnknown to true if all values matched by paths are equal in Plan & State func UnchangedPaths(paths ...path.Expression) UseStateForUnknownIfFunc { return func(ctx context.Context, req planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { From 56cc76577b0e53e6b59cad46f36a29db1b2fce68 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Mon, 24 Aug 2026 17:57:36 +0200 Subject: [PATCH 2/2] fix(ske): support running acc tests with open tofu --- stackit/internal/services/ske/ske_acc_test.go | 28 ++++++++++++ stackit/internal/testutil/plancheck.go | 45 +++++++++++++++++++ stackit/internal/testutil/testutil.go | 5 +++ stackit/internal/testutil/testutil_test.go | 12 +++++ 4 files changed, 90 insertions(+) create mode 100644 stackit/internal/testutil/plancheck.go diff --git a/stackit/internal/services/ske/ske_acc_test.go b/stackit/internal/services/ske/ske_acc_test.go index 452c53d15..a003e36fd 100644 --- a/stackit/internal/services/ske/ske_acc_test.go +++ b/stackit/internal/services/ske/ske_acc_test.go @@ -23,6 +23,8 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) +const ephemeralKubeconfigAddress = "ephemeral.stackit_ske_kubeconfig.ephemeral_kubeconfig" + var ( minTestName = "acc-min" + acctest.RandStringFromCharSet(3, acctest.CharSetAlpha) maxTestName = "acc-max" + acctest.RandStringFromCharSet(3, acctest.CharSetAlpha) @@ -138,6 +140,11 @@ func TestAccSKEMin(t *testing.T) { { Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMin, ConfigVariables: testConfigVarsMin, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + PostApplyPostRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + }, + ExpectNonEmptyPlan: testutil.UsingOpenTofu(), Check: resource.ComposeAggregateTestCheckFunc( // cluster data resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "project_id", testutil.ConvertConfigVariable(testConfigVarsMin["project_id"])), @@ -189,6 +196,11 @@ func TestAccSKEMin(t *testing.T) { { Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMin, ConfigVariables: testConfigVarsMin, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + PostApplyPostRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + }, + ExpectNonEmptyPlan: testutil.UsingOpenTofu(), Check: resource.ComposeAggregateTestCheckFunc( // cluster data @@ -247,7 +259,10 @@ func TestAccSKEMin(t *testing.T) { PreApply: []plancheck.PlanCheck{ plancheck.ExpectResourceAction("stackit_ske_cluster.cluster", plancheck.ResourceActionUpdate), }, + PostApplyPreRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + PostApplyPostRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, }, + ExpectNonEmptyPlan: testutil.UsingOpenTofu(), Check: resource.ComposeAggregateTestCheckFunc( // cluster data resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "project_id", testutil.ConvertConfigVariable(configVarsMinUpdated()["project_id"])), @@ -299,6 +314,11 @@ func TestAccSKEMax(t *testing.T) { { Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMax, ConfigVariables: testConfigVarsMax, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + PostApplyPostRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + }, + ExpectNonEmptyPlan: testutil.UsingOpenTofu(), Check: resource.ComposeAggregateTestCheckFunc( // cluster data resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "project_id", testutil.ConvertConfigVariable(testConfigVarsMax["project_id"])), @@ -389,6 +409,11 @@ func TestAccSKEMax(t *testing.T) { { Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentSKE).BuildProviderConfig() + "\n" + resourceMax, ConfigVariables: testConfigVarsMax, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + PostApplyPostRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + }, + ExpectNonEmptyPlan: testutil.UsingOpenTofu(), Check: resource.ComposeAggregateTestCheckFunc( // cluster data @@ -486,7 +511,10 @@ func TestAccSKEMax(t *testing.T) { PreApply: []plancheck.PlanCheck{ plancheck.ExpectResourceAction("stackit_ske_cluster.cluster", plancheck.ResourceActionUpdate), }, + PostApplyPreRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, + PostApplyPostRefresh: []plancheck.PlanCheck{testutil.ExpectOnlyEphemeralOpen(ephemeralKubeconfigAddress)}, }, + ExpectNonEmptyPlan: testutil.UsingOpenTofu(), Check: resource.ComposeAggregateTestCheckFunc( // cluster data resource.TestCheckResourceAttr("stackit_ske_cluster.cluster", "project_id", testutil.ConvertConfigVariable(configVarsMaxUpdated()["project_id"])), diff --git a/stackit/internal/testutil/plancheck.go b/stackit/internal/testutil/plancheck.go new file mode 100644 index 000000000..ae9f3e074 --- /dev/null +++ b/stackit/internal/testutil/plancheck.go @@ -0,0 +1,45 @@ +package testutil + +import ( + "context" + "fmt" + "slices" + + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +// ExpectOnlyEphemeralOpen returns a plan check that allows no-op actions and +// OpenTofu "open" actions for the given ephemeral resource addresses. +func ExpectOnlyEphemeralOpen(configAddresses ...string) plancheck.PlanCheck { + return expectOnlyEphemeralOpen{configAddresses: configAddresses} +} + +type expectOnlyEphemeralOpen struct { + configAddresses []string +} + +func (c expectOnlyEphemeralOpen) CheckPlan(_ context.Context, req plancheck.CheckPlanRequest, resp *plancheck.CheckPlanResponse) { + for _, resourceChange := range req.Plan.ResourceChanges { + if resourceChange.Change == nil || resourceChange.Change.Actions.NoOp() { + continue + } + + actions := resourceChange.Change.Actions + isSpecificResource := slices.Contains(c.configAddresses, resourceChange.Address) + if isSpecificResource && + string(resourceChange.Mode) == "ephemeral" && + len(actions) == 1 && string(actions[0]) == "open" { + continue + } + + resp.Error = fmt.Errorf("unexpected planned action(s) %v for %s", actions, resourceChange.Address) + return + } + + for name, outputChange := range req.Plan.OutputChanges { + if outputChange != nil && !outputChange.Actions.NoOp() { + resp.Error = fmt.Errorf("unexpected planned action(s) %v for output %s", outputChange.Actions, name) + return + } + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 3550d15f8..851e12cfe 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -27,6 +27,11 @@ const ( credentialsFilePath = ".stackit/credentials.json" //nolint:gosec // linter false positive ) +// UsingOpenTofu reports whether acceptance tests are running with the OpenTofu CLI. +func UsingOpenTofu() bool { + return strings.HasSuffix(os.Getenv("TF_ACC_TERRAFORM_PATH"), "tofu") +} + var ( // TestAccProtoV6ProviderFactories is used to instantiate a provider during // acceptance testing. The factory function will be invoked for every Terraform diff --git a/stackit/internal/testutil/testutil_test.go b/stackit/internal/testutil/testutil_test.go index cabfaf01f..291c279c2 100644 --- a/stackit/internal/testutil/testutil_test.go +++ b/stackit/internal/testutil/testutil_test.go @@ -49,6 +49,18 @@ func newTestState(resourceName string, attributes map[string]string) *terraform. } } +func TestUsingOpenTofu(t *testing.T) { + t.Setenv("TF_ACC_TERRAFORM_PATH", "/usr/local/bin/tofu") + if !UsingOpenTofu() { + t.Error("UsingOpenTofu() = false, want true") + } + + t.Setenv("TF_ACC_TERRAFORM_PATH", "/usr/local/bin/terraform") + if UsingOpenTofu() { + t.Error("UsingOpenTofu() = true, want false") + } +} + func TestConvertConfigVariable(t *testing.T) { tests := []struct { name string