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 0cb05a9af..08847b46d 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 @@ -3,6 +3,7 @@ package stringplanmodifier import ( "context" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -94,26 +95,36 @@ func StringUnchanged(attributePath path.Path) UseStateForUnknownIfFunc { // noli } } -// Int64Unchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state -func Int64Unchanged(attributePath path.Path) UseStateForUnknownIfFunc { // nolint:gocritic // function signature required by Terraform - return func(ctx context.Context, request planmodifier.StringRequest, response *UseStateForUnknownFuncResponse) { - var attributePlan types.Int64 - diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - var attributeState types.Int64 - 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) { + exprs := req.PathExpression.MergeExpressions(paths...) + for _, expr := range exprs { + matched, diags := req.Config.PathMatches(ctx, expr) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + for _, match := range matched { + var planValue attr.Value + resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, match, &planValue)...) + if resp.Diagnostics.HasError() { + return + } + + var stateValue attr.Value + resp.Diagnostics.Append(req.State.GetAttribute(ctx, match, &stateValue)...) + if resp.Diagnostics.HasError() { + return + } + + if !stateValue.Equal(planValue) { + resp.UseStateForUnknown = false + return + } + } } + resp.UseStateForUnknown = true } } diff --git a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go index 8e6813eee..be7a4b04a 100644 --- a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go +++ b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go @@ -4,7 +4,11 @@ import ( "context" "testing" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -126,3 +130,246 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { }) } } + +func TestUnchangedPaths(t *testing.T) { + ctx := t.Context() + itemAttributeTypes := map[string]attr.Type{ + "value": types.StringType, + } + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + // the attribute for which useStateForUnknown is decided + "anchor": schema.StringAttribute{Optional: true}, + // attributes used to make the decision + "first": schema.StringAttribute{Optional: true}, + "second": schema.StringAttribute{Optional: true}, + "enabled": schema.BoolAttribute{Optional: true}, + "count": schema.Int64Attribute{Optional: true}, + "items": schema.ListNestedAttribute{ + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "value": schema.StringAttribute{Optional: true}, + }, + }, + }, + }, + } + + type itemModel struct { + Value types.String `tfsdk:"value"` + } + type testModel struct { + Anchor types.String `tfsdk:"anchor"` + First types.String `tfsdk:"first"` + Second types.String `tfsdk:"second"` + Enabled types.Bool `tfsdk:"enabled"` + Count types.Int64 `tfsdk:"count"` + Items types.List `tfsdk:"items"` + } + type testValues struct { + anchor string + first string + second string + enabled bool + count int64 + items []string + } + + modelFromValues := func(t *testing.T, values testValues) testModel { + t.Helper() + + items := make([]itemModel, 0, len(values.items)) + for _, value := range values.items { + items = append(items, itemModel{Value: types.StringValue(value)}) + } + itemList, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: itemAttributeTypes}, items) + if diags.HasError() { + t.Fatalf("failed to construct item list: %v", diags.Errors()) + } + + return testModel{ + Anchor: types.StringValue(values.anchor), + First: types.StringValue(values.first), + Second: types.StringValue(values.second), + Enabled: types.BoolValue(values.enabled), + Count: types.Int64Value(values.count), + Items: itemList, + } + } + + newPlan := func(t *testing.T, values testValues) tfsdk.Plan { + t.Helper() + + plan := tfsdk.Plan{Schema: testSchema} + diags := plan.Set(ctx, modelFromValues(t, values)) + if diags.HasError() { + t.Fatalf("failed to construct plan: %v", diags.Errors()) + } + return plan + } + + newState := func(t *testing.T, values testValues) tfsdk.State { + t.Helper() + + state := tfsdk.State{Schema: testSchema} + diags := state.Set(ctx, modelFromValues(t, values)) + if diags.HasError() { + t.Fatalf("failed to construct state: %v", diags.Errors()) + } + return state + } + + relativeFirst := path.MatchRelative().AtParent().AtName("first") + relativeSecond := path.MatchRelative().AtParent().AtName("second") + allItemValues := path.MatchRoot("items").AtAnyListIndex().AtName("value") + items := path.MatchRoot("items") + + tests := []struct { + name string + paths []path.Expression + plan testValues + state testValues + configItems []string + want bool + wantError bool + }{ + { + name: "current attribute unchanged when no paths are supplied", + plan: testValues{anchor: "same", first: "first", second: "second"}, + state: testValues{anchor: "same", first: "first", second: "second"}, + want: true, + }, + { + name: "current attribute changed when no paths are supplied", + plan: testValues{anchor: "new", first: "first", second: "second"}, + state: testValues{anchor: "old", first: "first", second: "second"}, + }, + { + name: "relative path unchanged", + paths: []path.Expression{relativeFirst}, + plan: testValues{anchor: "new", first: "same", second: "second"}, + state: testValues{anchor: "old", first: "same", second: "second"}, + want: true, + }, + { + name: "relative path changed", + paths: []path.Expression{relativeFirst}, + plan: testValues{anchor: "same", first: "new", second: "second"}, + state: testValues{anchor: "same", first: "old", second: "second"}, + }, + { + name: "multiple paths unchanged", + paths: []path.Expression{relativeFirst, relativeSecond}, + plan: testValues{anchor: "new", first: "first", second: "second"}, + state: testValues{anchor: "old", first: "first", second: "second"}, + want: true, + }, + { + name: "one of multiple paths changed", + paths: []path.Expression{relativeFirst, relativeSecond}, + plan: testValues{anchor: "same", first: "first", second: "new"}, + state: testValues{anchor: "same", first: "first", second: "old"}, + }, + { + name: "non-string scalar unchanged", + paths: []path.Expression{path.MatchRoot("enabled"), path.MatchRoot("count")}, + plan: testValues{anchor: "new", enabled: true, count: 2}, + state: testValues{anchor: "old", enabled: true, count: 2}, + want: true, + }, + { + name: "non-string scalar changed", + paths: []path.Expression{path.MatchRoot("enabled"), path.MatchRoot("count")}, + plan: testValues{enabled: true, count: 2}, + state: testValues{enabled: true, count: 1}, + }, + { + name: "composite value unchanged", + paths: []path.Expression{items}, + plan: testValues{anchor: "new", items: []string{"one", "two"}}, + state: testValues{anchor: "old", items: []string{"one", "two"}}, + want: true, + }, + { + name: "composite value changed", + paths: []path.Expression{items}, + plan: testValues{items: []string{"one", "new"}}, + state: testValues{items: []string{"one", "old"}}, + }, + { + name: "all wildcard matches unchanged", + paths: []path.Expression{allItemValues}, + plan: testValues{anchor: "new", items: []string{"one", "two"}}, + state: testValues{anchor: "old", items: []string{"one", "two"}}, + want: true, + }, + { + name: "one wildcard match changed", + paths: []path.Expression{allItemValues}, + plan: testValues{anchor: "same", items: []string{"one", "new"}}, + state: testValues{anchor: "same", items: []string{"one", "old"}}, + }, + { + name: "no wildcard matches", + paths: []path.Expression{allItemValues}, + plan: testValues{anchor: "new", items: []string{}}, + state: testValues{anchor: "old", items: []string{}}, + want: true, + }, + { + name: "invalid path", + paths: []path.Expression{path.MatchRoot("missing")}, + plan: testValues{anchor: "same"}, + state: testValues{anchor: "same"}, + wantError: true, + }, + { + name: "matched path missing from plan is changed", + paths: []path.Expression{allItemValues}, + plan: testValues{items: []string{"one"}}, + state: testValues{items: []string{"one", "two"}}, + configItems: []string{"one", "two"}, + }, + { + name: "matched path missing from state is changed", + paths: []path.Expression{allItemValues}, + plan: testValues{items: []string{"one", "two"}}, + state: testValues{items: []string{"one"}}, + configItems: []string{"one", "two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan := newPlan(t, tt.plan) + state := newState(t, tt.state) + configPlan := plan + if tt.configItems != nil { + configValues := tt.plan + configValues.items = tt.configItems + configPlan = newPlan(t, configValues) + } + + request := planmodifier.StringRequest{ + PathExpression: path.MatchRoot("anchor"), + Config: tfsdk.Config{ + Schema: testSchema, + Raw: configPlan.Raw, + }, + Plan: plan, + State: state, + } + response := &UseStateForUnknownFuncResponse{} + + UnchangedPaths(tt.paths...)(ctx, request, response) + + if response.Diagnostics.HasError() != tt.wantError { + t.Fatalf("unexpected diagnostics: %v", response.Diagnostics) + } + if response.UseStateForUnknown != tt.want { + t.Errorf("UseStateForUnknown = %t, want %t", response.UseStateForUnknown, tt.want) + } + }) + } +}