From 4d9f6ab9379f8ab2c9106651449adc68d55b2a13 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 18 Aug 2026 11:02:43 +0200 Subject: [PATCH 1/8] fix(tests): fix compile, lint and runtime errors --- .../internal/services/iaas/utils/util_test.go | 19 ++++++++------- .../iaas/volume/unittest/resource_test.go | 24 +++++++++---------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/stackit/internal/services/iaas/utils/util_test.go b/stackit/internal/services/iaas/utils/util_test.go index c1adfbe79..144bcebc2 100644 --- a/stackit/internal/services/iaas/utils/util_test.go +++ b/stackit/internal/services/iaas/utils/util_test.go @@ -20,6 +20,7 @@ import ( "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/clientutils" ) const ( @@ -27,7 +28,7 @@ const ( testCustomEndpoint = "https://iaas-custom-endpoint.api.stackit.cloud" ) -func TestConfigureClient(t *testing.T) { +func TestDefaultClientFactoryNewIaaSV2Client(t *testing.T) { /* mock authentication by setting service account token env variable */ os.Clearenv() err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val") @@ -42,7 +43,7 @@ func TestConfigureClient(t *testing.T) { name string args args wantErr bool - expected *iaas.APIClient + expected iaas.DefaultAPI }{ { name: "default endpoint", @@ -51,14 +52,14 @@ func TestConfigureClient(t *testing.T) { Version: testVersion, }, }, - expected: func() *iaas.APIClient { + expected: func() iaas.DefaultAPI { apiClient, err := iaas.NewAPIClient( utils.UserAgentConfigOption(testVersion), ) if err != nil { t.Errorf("error configuring client: %v", err) } - return apiClient + return apiClient.DefaultAPI }(), wantErr: false, }, @@ -70,7 +71,7 @@ func TestConfigureClient(t *testing.T) { IaaSCustomEndpoint: testCustomEndpoint, }, }, - expected: func() *iaas.APIClient { + expected: func() iaas.DefaultAPI { apiClient, err := iaas.NewAPIClient( utils.UserAgentConfigOption(testVersion), config.WithEndpoint(testCustomEndpoint), @@ -78,7 +79,7 @@ func TestConfigureClient(t *testing.T) { if err != nil { t.Errorf("error configuring client: %v", err) } - return apiClient + return apiClient.DefaultAPI }(), wantErr: false, }, @@ -88,13 +89,13 @@ func TestConfigureClient(t *testing.T) { ctx := context.Background() diags := diag.Diagnostics{} - actual := ConfigureClient(ctx, tt.args.providerData, &diags) + actual := (&clientutils.DefaultClientFactory{}).NewIaaSV2Client(ctx, tt.args.providerData, &diags) if diags.HasError() != tt.wantErr { - t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + t.Errorf("NewIaaSV2Client() error = %v, want %v", diags.HasError(), tt.wantErr) } if !reflect.DeepEqual(actual, tt.expected) { - t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected) + t.Errorf("NewIaaSV2Client() = %v, want %v", actual, tt.expected) } }) } diff --git a/stackit/internal/services/iaas/volume/unittest/resource_test.go b/stackit/internal/services/iaas/volume/unittest/resource_test.go index 20b40ae58..f51b30027 100644 --- a/stackit/internal/services/iaas/volume/unittest/resource_test.go +++ b/stackit/internal/services/iaas/volume/unittest/resource_test.go @@ -2,12 +2,13 @@ package unittest import ( _ "embed" + "net/http" "testing" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/core/utils" iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" @@ -36,13 +37,17 @@ func TestVolumeResource(t *testing.T) { return vars } + var deleted bool mockClient := iaas.DefaultAPIServiceMock{ - CreateVolumeExecuteMock: utils.Ptr(func(r iaas.ApiCreateVolumeRequest) (*iaas.Volume, error) { + CreateVolumeExecuteMock: utils.Ptr(func(_ iaas.ApiCreateVolumeRequest) (*iaas.Volume, error) { return &iaas.Volume{ Id: new(volumeId), }, nil }), - GetVolumeExecuteMock: utils.Ptr(func(r iaas.ApiGetVolumeRequest) (*iaas.Volume, error) { + GetVolumeExecuteMock: utils.Ptr(func(_ iaas.ApiGetVolumeRequest) (*iaas.Volume, error) { + if deleted { + return nil, oapierror.NewError(http.StatusNotFound, "volume not found") + } return &iaas.Volume{ Id: new(volumeId), Status: new("AVAILABLE"), @@ -50,6 +55,10 @@ func TestVolumeResource(t *testing.T) { AvailabilityZone: "eu01-1", }, nil }), + DeleteVolumeExecuteMock: utils.Ptr(func(_ iaas.ApiDeleteVolumeRequest) error { + deleted = true + return nil + }), } resource.UnitTest(t, resource.TestCase{ @@ -61,15 +70,6 @@ func TestVolumeResource(t *testing.T) { Config: tfConfig, ConfigVariables: variables(), }, - { - Config: tfConfig, - ConfigVariables: variables(), - Check: func(s *terraform.State) error { - // Clear the root module resources so the auto-destroy finds nothing - s.RootModule().Resources = make(map[string]*terraform.ResourceState) - return nil - }, - }, }, }) } From cea9000890d18c10a2031fd3f509913b92bcde51 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 18 Aug 2026 12:29:44 +0200 Subject: [PATCH 2/8] feat(clients): add defaultConfigOptions, add response trace id logging --- .../internal/utils/clientutils/clientutils.go | 51 +++++++++++++----- .../utils/clientutils/clientutils_test.go | 52 +++++++++++++++++++ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/stackit/internal/utils/clientutils/clientutils.go b/stackit/internal/utils/clientutils/clientutils.go index dd5857c7a..15466f43b 100644 --- a/stackit/internal/utils/clientutils/clientutils.go +++ b/stackit/internal/utils/clientutils/clientutils.go @@ -3,8 +3,10 @@ package clientutils import ( "context" "fmt" + "net/http" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/config" iaasV2 "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" serviceenablementV2 "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api" @@ -22,17 +24,44 @@ type ClientFactory interface { NewIaaSV2Client(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) iaasV2.DefaultAPI } -type DefaultClientFactory struct { -} - -func (f *DefaultClientFactory) NewServiceEnablementV2Client(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) serviceenablementV2.DefaultAPI { - apiClientConfigOptions := []config.ConfigurationOption{ +func defaultConfigOptions(providerData *core.ProviderData, customEndpoint string) []config.ConfigurationOption { + options := []config.ConfigurationOption{ config.WithCustomAuth(providerData.RoundTripper), utils.UserAgentConfigOption(providerData.Version), + config.WithMiddleware(responseLoggingMiddleware), + } + if customEndpoint != "" { + options = append(options, config.WithEndpoint(customEndpoint)) } - if providerData.ServiceEnablementCustomEndpoint != "" { - apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ServiceEnablementCustomEndpoint)) + return options +} + +func responseLoggingMiddleware(next http.RoundTripper) http.RoundTripper { + return responseLoggingRoundTripper{next: next} +} + +type responseLoggingRoundTripper struct { + next http.RoundTripper +} + +func (rt responseLoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + resp, err := rt.next.RoundTrip(req) + if err != nil { + return resp, err } + traceId := resp.Header.Get("x-trace-id") + tflog.Info(ctx, "response data", map[string]any{ + "x-trace-id": traceId, + }) + return resp, err +} + +type DefaultClientFactory struct { +} + +func (f *DefaultClientFactory) NewServiceEnablementV2Client(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) serviceenablementV2.DefaultAPI { + apiClientConfigOptions := defaultConfigOptions(providerData, providerData.ServiceEnablementCustomEndpoint) apiClient, err := serviceenablementV2.NewAPIClient(apiClientConfigOptions...) if err != nil { core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) @@ -43,13 +72,7 @@ func (f *DefaultClientFactory) NewServiceEnablementV2Client(ctx context.Context, } func (f *DefaultClientFactory) NewIaaSV2Client(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) iaasV2.DefaultAPI { - apiClientConfigOptions := []config.ConfigurationOption{ - config.WithCustomAuth(providerData.RoundTripper), - utils.UserAgentConfigOption(providerData.Version), - } - if providerData.IaaSCustomEndpoint != "" { - apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.IaaSCustomEndpoint)) - } + apiClientConfigOptions := defaultConfigOptions(providerData, providerData.IaaSCustomEndpoint) apiClient, err := iaasV2.NewAPIClient(apiClientConfigOptions...) if err != nil { core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) diff --git a/stackit/internal/utils/clientutils/clientutils_test.go b/stackit/internal/utils/clientutils/clientutils_test.go index e5a4e539f..48f3af8bd 100644 --- a/stackit/internal/utils/clientutils/clientutils_test.go +++ b/stackit/internal/utils/clientutils/clientutils_test.go @@ -2,15 +2,67 @@ package clientutils import ( "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "reflect" + "strings" "testing" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-log/tfsdklog" + "github.com/stackitcloud/stackit-sdk-go/core/auth" "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" ) +func TestResponseLoggingMiddlewareLogsTraceID(t *testing.T) { + const traceID = "test-trace-id" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("x-trace-id", traceID) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[]}`)) + })) + defer server.Close() + + logPath := filepath.Join(t.TempDir(), "terraform.log") + t.Setenv("TF_LOG", "JSON") + t.Setenv("TF_LOG_PATH", logPath) + + ctx := tfsdklog.ContextWithTestLogging(context.Background(), t.Name()) + ctx = tfsdklog.NewRootProviderLogger(ctx) + + rt, err := auth.NoAuth() + if err != nil { + t.Fatal(err) + } + client := (&DefaultClientFactory{}).NewServiceEnablementV2Client(ctx, &core.ProviderData{ + RoundTripper: rt, + ServiceEnablementCustomEndpoint: server.URL, + }, &diag.Diagnostics{}) + if client == nil { + t.Fatal("NewServiceEnablementV2Client() returned nil") + } + + if _, err := client.ListServiceStatusRegional(ctx, "eu01", "project-id").Execute(); err != nil { + t.Fatalf("ListServiceStatusRegional().Execute() error = %v", err) + } + + logOutput, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("reading log output: %v", err) + } + if !strings.Contains(string(logOutput), "response data") { + t.Errorf("log output does not contain response data: %s", logOutput) + } + if !strings.Contains(string(logOutput), traceID) { + t.Errorf("log output does not contain trace ID %q: %s", traceID, logOutput) + } +} + func TestDefaultClientFactory_NewServiceEnablementV2Client(t *testing.T) { type args struct { ctx context.Context From 1b62e722da404a04be026fbda9558bee879a16dd Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 18 Aug 2026 12:50:46 +0200 Subject: [PATCH 3/8] feat(log): add logging funcs in core to also log x-trace-id --- stackit/internal/core/core.go | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 478b92c71..1d5be7cd5 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -120,6 +120,46 @@ func DiagsToError(diags diag.Diagnostics) error { return fmt.Errorf("%s", strings.Join(diagsStrings, ";")) } +// LogTrace logs a trace-level message. +func LogTrace(ctx context.Context, msg string, additionalFields ...map[string]interface{}) { + additionalFields = append(additionalFields, map[string]interface{}{ + "x-trace-id": runtime.GetTraceId(ctx), + }) + tflog.Trace(ctx, msg, additionalFields...) +} + +// LogDebug logs a debug-level message. +func LogDebug(ctx context.Context, msg string, additionalFields ...map[string]interface{}) { + additionalFields = append(additionalFields, map[string]interface{}{ + "x-trace-id": runtime.GetTraceId(ctx), + }) + tflog.Debug(ctx, msg, additionalFields...) +} + +// LogInfo logs an info-level message. +func LogInfo(ctx context.Context, msg string, additionalFields ...map[string]interface{}) { + additionalFields = append(additionalFields, map[string]interface{}{ + "x-trace-id": runtime.GetTraceId(ctx), + }) + tflog.Info(ctx, msg, additionalFields...) +} + +// LogWarn logs a warning-level message. +func LogWarn(ctx context.Context, msg string, additionalFields ...map[string]interface{}) { + additionalFields = append(additionalFields, map[string]interface{}{ + "x-trace-id": runtime.GetTraceId(ctx), + }) + tflog.Warn(ctx, msg, additionalFields...) +} + +// LogError logs an error-level message. +func LogError(ctx context.Context, msg string, additionalFields ...map[string]interface{}) { + additionalFields = append(additionalFields, map[string]interface{}{ + "x-trace-id": runtime.GetTraceId(ctx), + }) + tflog.Error(ctx, msg, additionalFields...) +} + // LogAndAddError Logs the error and adds it to the diags func LogAndAddError(ctx context.Context, diags *diag.Diagnostics, summary, detail string) { if traceId := runtime.GetTraceId(ctx); traceId != "" { From 1f3cbf1036088d7c00944172c96c8e83a4e77d20 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 18 Aug 2026 14:46:05 +0200 Subject: [PATCH 4/8] feat(logging): setup response capture for all resources+data sources --- stackit/internal/core/wrap.go | 214 +++++++++++++++++++++++++++++ stackit/internal/core/wrap_test.go | 75 ++++++++++ stackit/provider.go | 12 ++ 3 files changed, 301 insertions(+) create mode 100644 stackit/internal/core/wrap.go create mode 100644 stackit/internal/core/wrap_test.go diff --git a/stackit/internal/core/wrap.go b/stackit/internal/core/wrap.go new file mode 100644 index 000000000..8e0a36854 --- /dev/null +++ b/stackit/internal/core/wrap.go @@ -0,0 +1,214 @@ +package core + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// WrapDataSource wraps a data source so its Read method can capture the HTTP +// response used for provider logging. +func WrapDataSource(inner datasource.DataSource) datasource.DataSource { + return &wrappedDataSource{inner: inner} +} + +type wrappedDataSource struct { + inner datasource.DataSource +} + +var ( + _ datasource.DataSource = &wrappedDataSource{} + _ datasource.DataSourceWithConfigure = &wrappedDataSource{} + _ datasource.DataSourceWithConfigValidators = &wrappedDataSource{} + _ datasource.DataSourceWithValidateConfig = &wrappedDataSource{} +) + +func (w *wrappedDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + w.inner.Metadata(ctx, req, resp) +} + +func (w *wrappedDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + w.inner.Schema(ctx, req, resp) +} + +func (w *wrappedDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:gocritic,tflogresponse // Framework signature requires values; wrapped method logs the response. + ctx = InitProviderContext(ctx) //nolint:tflogresponse // The wrapped CRUD method logs the response. + w.inner.Read(ctx, req, resp) +} + +func (w *wrappedDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if inner, ok := w.inner.(datasource.DataSourceWithConfigure); ok { + inner.Configure(ctx, req, resp) + } +} + +func (w *wrappedDataSource) ConfigValidators(ctx context.Context) []datasource.ConfigValidator { + if inner, ok := w.inner.(datasource.DataSourceWithConfigValidators); ok { + return inner.ConfigValidators(ctx) + } + + return nil +} + +func (w *wrappedDataSource) ValidateConfig(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + if inner, ok := w.inner.(datasource.DataSourceWithValidateConfig); ok { + inner.ValidateConfig(ctx, req, resp) + } +} + +// WrapResource wraps a resource so its CRUD methods can capture the HTTP +// response used for provider logging. +func WrapResource(inner resource.Resource) resource.Resource { + wrapped := &wrappedResource{inner: inner} + _, hasIdentity := inner.(resource.ResourceWithIdentity) + _, hasUpgradeIdentity := inner.(resource.ResourceWithUpgradeIdentity) + + switch { + case hasIdentity && hasUpgradeIdentity: + return &wrappedResourceWithIdentityAndUpgrade{wrappedResourceWithIdentity: wrappedResourceWithIdentity{wrappedResource: wrapped}} + case hasIdentity: + return &wrappedResourceWithIdentity{wrappedResource: wrapped} + case hasUpgradeIdentity: + return &wrappedResourceWithUpgradeIdentity{wrappedResource: wrapped} + default: + return wrapped + } +} + +type wrappedResource struct { + inner resource.Resource +} + +var ( + _ resource.Resource = &wrappedResource{} + _ resource.ResourceWithConfigure = &wrappedResource{} + _ resource.ResourceWithConfigValidators = &wrappedResource{} + _ resource.ResourceWithImportState = &wrappedResource{} + _ resource.ResourceWithModifyPlan = &wrappedResource{} + _ resource.ResourceWithMoveState = &wrappedResource{} + _ resource.ResourceWithUpgradeState = &wrappedResource{} + _ resource.ResourceWithValidateConfig = &wrappedResource{} +) + +func (w *wrappedResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + w.inner.Metadata(ctx, req, resp) +} + +func (w *wrappedResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + w.inner.Schema(ctx, req, resp) +} + +func (w *wrappedResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { //nolint:gocritic,tflogresponse // Framework signature requires values; wrapped method logs the response. + ctx = InitProviderContext(ctx) //nolint:tflogresponse // The wrapped CRUD method logs the response. + w.inner.Create(ctx, req, resp) +} + +func (w *wrappedResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { //nolint:gocritic,tflogresponse // Framework signature requires values; wrapped method logs the response. + ctx = InitProviderContext(ctx) //nolint:tflogresponse // The wrapped CRUD method logs the response. + w.inner.Read(ctx, req, resp) +} + +func (w *wrappedResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { //nolint:gocritic,tflogresponse // Framework signature requires values; wrapped method logs the response. + ctx = InitProviderContext(ctx) //nolint:tflogresponse // The wrapped CRUD method logs the response. + w.inner.Update(ctx, req, resp) +} + +func (w *wrappedResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { //nolint:gocritic,tflogresponse // Framework signature requires values; wrapped method logs the response. + ctx = InitProviderContext(ctx) //nolint:tflogresponse // The wrapped CRUD method logs the response. + w.inner.Delete(ctx, req, resp) +} + +func (w *wrappedResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if inner, ok := w.inner.(resource.ResourceWithConfigure); ok { + inner.Configure(ctx, req, resp) + } +} + +func (w *wrappedResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { + if inner, ok := w.inner.(resource.ResourceWithConfigValidators); ok { + return inner.ConfigValidators(ctx) + } + + return nil +} + +func (w *wrappedResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + if inner, ok := w.inner.(resource.ResourceWithImportState); ok { + inner.ImportState(ctx, req, resp) + return + } + + resp.Diagnostics.AddError( + "Resource Import Not Implemented", + "This resource does not support import. Please contact the provider developer for additional information.", + ) +} + +func (w *wrappedResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { //nolint:gocritic // Framework signature requires values. + if inner, ok := w.inner.(resource.ResourceWithModifyPlan); ok { + inner.ModifyPlan(ctx, req, resp) + } +} + +func (w *wrappedResource) MoveState(ctx context.Context) []resource.StateMover { + if inner, ok := w.inner.(resource.ResourceWithMoveState); ok { + return inner.MoveState(ctx) + } + + return nil +} + +func (w *wrappedResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + if inner, ok := w.inner.(resource.ResourceWithUpgradeState); ok { + return inner.UpgradeState(ctx) + } + + return nil +} + +func (w *wrappedResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + if inner, ok := w.inner.(resource.ResourceWithValidateConfig); ok { + inner.ValidateConfig(ctx, req, resp) + } +} + +type wrappedResourceWithIdentity struct { + *wrappedResource +} + +var _ resource.ResourceWithIdentity = &wrappedResourceWithIdentity{} + +func (w *wrappedResourceWithIdentity) IdentitySchema(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + if inner, ok := w.inner.(resource.ResourceWithIdentity); ok { + inner.IdentitySchema(ctx, req, resp) + } +} + +type wrappedResourceWithUpgradeIdentity struct { + *wrappedResource +} + +var _ resource.ResourceWithUpgradeIdentity = &wrappedResourceWithUpgradeIdentity{} + +func (w *wrappedResourceWithUpgradeIdentity) UpgradeIdentity(ctx context.Context) map[int64]resource.IdentityUpgrader { + if inner, ok := w.inner.(resource.ResourceWithUpgradeIdentity); ok { + return inner.UpgradeIdentity(ctx) + } + + return nil +} + +type wrappedResourceWithIdentityAndUpgrade struct { + wrappedResourceWithIdentity +} + +var _ resource.ResourceWithUpgradeIdentity = &wrappedResourceWithIdentityAndUpgrade{} + +func (w *wrappedResourceWithIdentityAndUpgrade) UpgradeIdentity(ctx context.Context) map[int64]resource.IdentityUpgrader { + if inner, ok := w.inner.(resource.ResourceWithUpgradeIdentity); ok { + return inner.UpgradeIdentity(ctx) + } + + return nil +} diff --git a/stackit/internal/core/wrap_test.go b/stackit/internal/core/wrap_test.go new file mode 100644 index 000000000..3c6c56e90 --- /dev/null +++ b/stackit/internal/core/wrap_test.go @@ -0,0 +1,75 @@ +package core + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/resource" + sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +type testDataSource struct { + readContext context.Context +} + +func (d *testDataSource) Metadata(context.Context, datasource.MetadataRequest, *datasource.MetadataResponse) { +} +func (d *testDataSource) Schema(context.Context, datasource.SchemaRequest, *datasource.SchemaResponse) { +} +func (d *testDataSource) Read(ctx context.Context, _ datasource.ReadRequest, _ *datasource.ReadResponse) { //nolint:gocritic // Framework signature requires values. + d.readContext = ctx +} + +type testResource struct { + contexts map[string]context.Context +} + +func (r *testResource) Metadata(context.Context, resource.MetadataRequest, *resource.MetadataResponse) { +} +func (r *testResource) Schema(context.Context, resource.SchemaRequest, *resource.SchemaResponse) {} +func (r *testResource) Create(ctx context.Context, _ resource.CreateRequest, _ *resource.CreateResponse) { //nolint:gocritic // Framework signature requires values. + r.contexts["create"] = ctx +} +func (r *testResource) Read(ctx context.Context, _ resource.ReadRequest, _ *resource.ReadResponse) { //nolint:gocritic // Framework signature requires values. + r.contexts["read"] = ctx +} +func (r *testResource) Update(ctx context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { //nolint:gocritic // Framework signature requires values. + r.contexts["update"] = ctx +} +func (r *testResource) Delete(ctx context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { //nolint:gocritic // Framework signature requires values. + r.contexts["delete"] = ctx +} + +func TestWrapDataSourceReadInitializesProviderContext(t *testing.T) { + inner := &testDataSource{} + wrapped := WrapDataSource(inner) + + wrapped.Read(context.Background(), datasource.ReadRequest{}, &datasource.ReadResponse{}) + + if inner.readContext.Value(sdkconfig.ContextHTTPResponse) == nil { + t.Error("Read context does not capture HTTP responses") + } +} + +func TestWrapResourceDoesNotAddIdentitySupport(t *testing.T) { + if _, ok := WrapResource(&testResource{}).(resource.ResourceWithIdentity); ok { + t.Error("wrapper must not add identity support when the resource does not implement it") + } +} + +func TestWrapResourceCRUDInitializesProviderContext(t *testing.T) { + inner := &testResource{contexts: make(map[string]context.Context)} + wrapped := WrapResource(inner) + + wrapped.Create(context.Background(), resource.CreateRequest{}, &resource.CreateResponse{}) + wrapped.Read(context.Background(), resource.ReadRequest{}, &resource.ReadResponse{}) + wrapped.Update(context.Background(), resource.UpdateRequest{}, &resource.UpdateResponse{}) + wrapped.Delete(context.Background(), resource.DeleteRequest{}, &resource.DeleteResponse{}) + + for _, operation := range []string{"create", "read", "update", "delete"} { + if inner.contexts[operation].Value(sdkconfig.ContextHTTPResponse) == nil { + t.Errorf("%s context does not capture HTTP responses", operation) + } + } +} diff --git a/stackit/provider.go b/stackit/provider.go index 8cb945c0e..47cd72f94 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -812,6 +812,12 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource }) } + for i, factory := range dataSources { + dataSources[i] = func() datasource.DataSource { + return core.WrapDataSource(factory()) + } + } + return dataSources } @@ -933,6 +939,12 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { }) } + for i, factory := range resources { + resources[i] = func() resource.Resource { + return core.WrapResource(factory()) + } + } + return resources } From 323075a5772f8867d153d6e69296af455d98dcd5 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 18 Aug 2026 14:54:59 +0200 Subject: [PATCH 5/8] do not merge this one demo logging for data source machine type --- stackit/internal/services/iaas/machinetype/datasource.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stackit/internal/services/iaas/machinetype/datasource.go b/stackit/internal/services/iaas/machinetype/datasource.go index a94f6fbd2..3395e69f1 100644 --- a/stackit/internal/services/iaas/machinetype/datasource.go +++ b/stackit/internal/services/iaas/machinetype/datasource.go @@ -158,7 +158,7 @@ func (d *machineTypeDataSource) Read(ctx context.Context, req datasource.ReadReq region := d.providerData.GetRegionWithOverride(model.Region) sortAscending := model.SortAscending.ValueBool() - ctx = core.InitProviderContext(ctx) + //ctx = core.InitProviderContext(ctx) ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "region", region) @@ -183,7 +183,7 @@ func (d *machineTypeDataSource) Read(ctx context.Context, req datasource.ReadReq return } - ctx = core.LogResponse(ctx) + //ctx = core.LogResponse(ctx) if len(apiResp.Items) == 0 { core.LogAndAddWarning(ctx, &resp.Diagnostics, "No machine types found", "No matching machine types.") @@ -211,7 +211,7 @@ func (d *machineTypeDataSource) Read(ctx context.Context, req datasource.ReadReq if resp.Diagnostics.HasError() { return } - tflog.Info(ctx, "Successfully read machine type") + core.LogInfo(ctx, "Successfully read machine type") } func mapDataSourceFields(ctx context.Context, machineType *iaas.MachineType, model *DataSourceModel, region string) error { From 55bbcac7cc80a80e1d732172bd9676e76a5890ef Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 18 Aug 2026 15:10:17 +0200 Subject: [PATCH 6/8] fix(machinetype): add to refactored data sources --- stackit/internal/services/iaas/machinetype/datasource.go | 6 +++--- stackit/provider.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/stackit/internal/services/iaas/machinetype/datasource.go b/stackit/internal/services/iaas/machinetype/datasource.go index 3395e69f1..e32ab6f99 100644 --- a/stackit/internal/services/iaas/machinetype/datasource.go +++ b/stackit/internal/services/iaas/machinetype/datasource.go @@ -44,8 +44,8 @@ type DataSourceModel struct { } // NewMachineTypeDataSource instantiates the data source -func NewMachineTypeDataSource() datasource.DataSource { - return &machineTypeDataSource{} +func NewMachineTypeDataSource(clientFactory clientutils.ClientFactory) datasource.DataSource { + return &machineTypeDataSource{clientFactory: clientFactory} } type machineTypeDataSource struct { @@ -76,7 +76,7 @@ func (d *machineTypeDataSource) Configure(ctx context.Context, req datasource.Co return } - tflog.Info(ctx, "IAAS client configured") + core.LogInfo(ctx, "IAAS client configured") } func (d *machineTypeDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { diff --git a/stackit/provider.go b/stackit/provider.go index 47cd72f94..3c89c524f 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -18,6 +18,7 @@ import ( sdkauth "github.com/stackitcloud/stackit-sdk-go/core/auth" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" + machineType "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/machinetype" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/clientutils" @@ -45,7 +46,6 @@ import ( iaasImage "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/image" iaasImageV2 "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/imagev2" iaasKeyPair "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/keypair" - machineType "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/machinetype" iaasNetwork "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/network" iaasNetworkArea "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarea" iaasNetworkAreaRegion "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarearegion" @@ -722,7 +722,6 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource logsInstance.NewLogsInstanceDataSource, logsAccessToken.NewLogsAccessTokenDataSource, logAlertGroup.NewLogAlertGroupDataSource, - machineType.NewMachineTypeDataSource, mariaDBInstance.NewInstanceDataSource, mariaDBCredential.NewCredentialDataSource, mongoDBFlexInstance.NewInstanceDataSource, @@ -803,6 +802,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasRoutingTableRoutes.NewRoutingTableRoutesDataSource, iaasSecurityGroupRule.NewSecurityGroupRuleDataSource, iaasVolume.NewVolumeDataSource, + machineType.NewMachineTypeDataSource, } // won't be needed after refactoring is completed From 4de58022b159d75159ce97316d2bd01db1e15d45 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Wed, 19 Aug 2026 11:30:22 +0200 Subject: [PATCH 7/8] feat(modifiers): add UnchangedPaths - delete unused Int64Unchanged --- .../use_state_for_unknown_if.go | 51 ++-- .../use_state_for_unknown_if_test.go | 247 ++++++++++++++++++ 2 files changed, 278 insertions(+), 20 deletions(-) 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..3e71bbc74 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...) + allUnchanged := true + 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) { + allUnchanged = false + } + } } + resp.UseStateForUnknown = allUnchanged } } 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) + } + }) + } +} From 8ef7f3bcf3ab30623b757ef5998a06f81f5a0c42 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Wed, 19 Aug 2026 11:30:50 +0200 Subject: [PATCH 8/8] Revert "feat(modifiers): add UnchangedPaths" This reverts commit 4de58022b159d75159ce97316d2bd01db1e15d45. --- .../use_state_for_unknown_if.go | 51 ++-- .../use_state_for_unknown_if_test.go | 247 ------------------ 2 files changed, 20 insertions(+), 278 deletions(-) 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 3e71bbc74..0cb05a9af 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,7 +3,6 @@ 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" @@ -95,36 +94,26 @@ func StringUnchanged(attributePath path.Path) UseStateForUnknownIfFunc { // noli } } -// 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...) - allUnchanged := true - 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) { - allUnchanged = false - } - } +// 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 } - resp.UseStateForUnknown = allUnchanged } } 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 be7a4b04a..8e6813eee 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,11 +4,7 @@ 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" ) @@ -130,246 +126,3 @@ 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) - } - }) - } -}