From 243f83123c0018c25ee454eae9b05dda3e3f4a94 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 17 Feb 2026 17:39:20 +0100 Subject: [PATCH 1/7] fix(rabbitmq): Store IDs immediately after provisioning STACKITTPR-390 --- .../services/rabbitmq/credential/resource.go | 21 ++++++++++++---- .../services/rabbitmq/instance/resource.go | 24 +++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/stackit/internal/services/rabbitmq/credential/resource.go b/stackit/internal/services/rabbitmq/credential/resource.go index 5fac9becf..50b5fecaa 100644 --- a/stackit/internal/services/rabbitmq/credential/resource.go +++ b/stackit/internal/services/rabbitmq/credential/resource.go @@ -15,7 +15,6 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -204,7 +203,14 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ return } credentialId := *credentialsResp.Id - ctx = tflog.SetField(ctx, "credential_id", credentialId) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "instance_id": instanceId, + "credential_id": credentialId, + }) + if resp.Diagnostics.HasError() { + return + } waitResp, err := wait.CreateCredentialsWaitHandler(ctx, r.client, projectId, instanceId, credentialId).WaitWithContext(ctx) if err != nil { @@ -325,9 +331,14 @@ func (r *credentialResource) ImportState(ctx context.Context, req resource.Impor return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("credential_id"), idParts[2])...) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "instance_id": idParts[1], + "credential_id": idParts[2], + }) + if resp.Diagnostics.HasError() { + return + } tflog.Info(ctx, "RabbitMQ credential state imported") } diff --git a/stackit/internal/services/rabbitmq/instance/resource.go b/stackit/internal/services/rabbitmq/instance/resource.go index 430d619bb..8d853dcab 100644 --- a/stackit/internal/services/rabbitmq/instance/resource.go +++ b/stackit/internal/services/rabbitmq/instance/resource.go @@ -19,7 +19,6 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -363,8 +362,20 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques ctx = core.LogResponse(ctx) + if createResp.InstanceId == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "API response did not include instance ID") + return + } + instanceId := *createResp.InstanceId - ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "instance_id": instanceId, + }) + if resp.Diagnostics.HasError() { + return + } + waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client, projectId, instanceId).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Instance creation waiting: %v", err)) @@ -554,8 +565,13 @@ func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportS return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "instance_id": idParts[1], + }) + if resp.Diagnostics.HasError() { + return + } tflog.Info(ctx, "RabbitMQ instance state imported") } From 3e9999f1ab37930c71b850af647e7f77343e7917 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Thu, 19 Feb 2026 11:04:44 +0100 Subject: [PATCH 2/7] chore(rabbitmq) write tests for saving IDs on create error --- .../services/rabbitmq/rabbitmq_acc_test.go | 194 ++++++++++++++++++ stackit/internal/testutil/mockserver.go | 67 ++++++ 2 files changed, 261 insertions(+) create mode 100644 stackit/internal/testutil/mockserver.go diff --git a/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go b/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go index ff9d3f417..d6d984191 100644 --- a/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go +++ b/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go @@ -3,10 +3,12 @@ package rabbitmq_test import ( "context" "fmt" + "net/http" "regexp" "strings" "testing" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -243,6 +245,198 @@ func TestAccRabbitMQResource(t *testing.T) { }) } +// Run apply for an instance and produce an error in the waiter. By erroring out state checks are not run in this step. +// The second step imports the resource and runs a state check to verify that the first step wrote the IDs, despite the error. +// When importing we append "-import" to the credential ID to verify that the import isn't overwriting the instance +// ID from the first step +func TestRabbitMQInstanceSavesIDsOnError(t *testing.T) { + projectId := uuid.NewString() + instanceId := uuid.NewString() + const ( + name = "instance-name" + planName = "plan-name" + planId = "plan-id" + version = "version" + ) + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + rabbitmq_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_rabbitmq_instance" "instance" { + project_id = "%s" + name = "%s" + plan_name = "%s" + version = "%s" +} +`, s.Server.URL, projectId, name, planName, version) + offerings := testutil.MockResponse{ + ToJsonBody: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr(version), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr(planName), + Id: utils.Ptr(planId), + }, + }, + }, + }, + }, + } + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + // respond to listing offerings + offerings, + // initial post response + testutil.MockResponse{ + ToJsonBody: rabbitmq.CreateInstanceResponse{ + InstanceId: utils.Ptr(instanceId), + }, + }, + // failing waiter + testutil.MockResponse{ + ToJsonBody: rabbitmq.Instance{ + Status: utils.Ptr(rabbitmq.INSTANCESTATUS_FAILED), + }, + }, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating instance.*"), + }, + { + PreConfig: func() { + s.Reset( + // read from import + testutil.MockResponse{ + ToJsonBody: rabbitmq.Instance{ + Status: utils.Ptr(rabbitmq.INSTANCESTATUS_ACTIVE), + InstanceId: utils.Ptr(instanceId + "-import"), + PlanId: utils.Ptr(planId), + }, + }, + // list offerings in import + offerings, + // delete + testutil.MockResponse{StatusCode: http.StatusAccepted}, + // delete waiter + testutil.MockResponse{ + StatusCode: http.StatusGone, + }, + ) + }, + ImportStateCheck: func(states []*terraform.InstanceState) error { + if len(states) != 1 { + return fmt.Errorf("expected exactly one state to be imported, got %d", len(states)) + } + state := states[0] + if state.Attributes["instance_id"] != instanceId { + return fmt.Errorf("expected instance_id to be %s, got %s", instanceId, state.Attributes["instance_id"]) + } + if state.Attributes["project_id"] != projectId { + return fmt.Errorf("expected project_id to be %s, got %s", projectId, state.Attributes["project_id"]) + } + return nil + }, + ImportState: true, + ImportStateId: fmt.Sprintf("%s,%s", projectId, instanceId), + ResourceName: "stackit_rabbitmq_instance.instance", + }, + }, + }) +} + +// Run apply for credentials and produce an error in the waiter. By erroring out state checks are not run in this step. +// The second step imports the resource and runs a state check to verify that the first step wrote the IDs, despite the error. +// When importing we append "-import" to the credential ID to verify that the import isn't overwriting the credential +// ID from the first step +func TestRabbitMQCredentialsSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + instanceId = uuid.NewString() + credentialId = uuid.NewString() + ) + s := testutil.NewMockServer(t) + t.Cleanup(s.Server.Close) + tfConfig := fmt.Sprintf(` +provider "stackit" { + rabbitmq_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_rabbitmq_credential" "credential" { + project_id = "%s" + instance_id = "%s" +} +`, s.Server.URL, projectId, instanceId) + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + // initial post response + testutil.MockResponse{ + ToJsonBody: rabbitmq.CredentialsResponse{ + Id: utils.Ptr(credentialId), + }, + }, + // failing waiter + testutil.MockResponse{StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating credential.*"), + }, + { + PreConfig: func() { + s.Reset( + // read from import + testutil.MockResponse{ + ToJsonBody: rabbitmq.CredentialsResponse{ + Id: utils.Ptr(credentialId + "-import"), + Raw: &rabbitmq.RawCredentials{}, + }, + }, + // delete + testutil.MockResponse{StatusCode: http.StatusAccepted}, + // delete waiter + testutil.MockResponse{StatusCode: http.StatusGone}, + ) + }, + ImportStateCheck: func(states []*terraform.InstanceState) error { + if len(states) != 1 { + return fmt.Errorf("expected exactly one state to be imported, got %d", len(states)) + } + state := states[0] + if state.Attributes["instance_id"] != instanceId { + return fmt.Errorf("expected instance_id to be %s, got %s", instanceId, state.Attributes["instance_id"]) + } + if state.Attributes["project_id"] != projectId { + return fmt.Errorf("expected project_id to be %s, got %s", projectId, state.Attributes["project_id"]) + } + if state.Attributes["credential_id"] != credentialId { + return fmt.Errorf("expected credential_id to be %s, got %s", credentialId, state.Attributes["credential_id"]) + } + return nil + }, + ImportState: true, + ImportStateId: fmt.Sprintf("%s,%s,%s", projectId, instanceId, credentialId), + ResourceName: "stackit_rabbitmq_credential.credential", + }, + }, + }) +} + func testAccCheckRabbitMQDestroy(s *terraform.State) error { ctx := context.Background() var client *rabbitmq.APIClient diff --git a/stackit/internal/testutil/mockserver.go b/stackit/internal/testutil/mockserver.go new file mode 100644 index 000000000..504690b09 --- /dev/null +++ b/stackit/internal/testutil/mockserver.go @@ -0,0 +1,67 @@ +package testutil + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" +) + +type MockResponse struct { + StatusCode int + Description string + ToJsonBody any +} + +var _ http.Handler = (*MockServer)(nil) + +type MockServer struct { + mu sync.Mutex + nextResponse int + responses []MockResponse + Server *httptest.Server + t *testing.T +} + +// NewMockServer creates a new simple mock server that returns `responses` in order for each request. +// Use the `Reset` method to reset the response order and set new responses. +func NewMockServer(t *testing.T, responses ...MockResponse) *MockServer { + mock := &MockServer{ + nextResponse: 0, + responses: responses, + t: t, + } + mock.Server = httptest.NewServer(mock) + return mock +} + +func (m *MockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + if m.nextResponse >= len(m.responses) { + m.t.Fatalf("No more responses left in the mock server for request: %v", r) + } + next := m.responses[m.nextResponse] + m.nextResponse++ + if next.ToJsonBody != nil { + bs, err := json.Marshal(next.ToJsonBody) + if err != nil { + m.t.Fatalf("Error marshaling response body: %v", err) + } + w.Header().Set("content-type", "application/json") + w.Write(bs) + } + status := next.StatusCode + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) +} + +func (m *MockServer) Reset(responses ...MockResponse) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextResponse = 0 + m.responses = responses +} From f5fc4e56d1da22c9ad540abee5aab780c0d3ab15 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Thu, 19 Feb 2026 11:09:55 +0100 Subject: [PATCH 3/7] fix(lint) ignore write error in mockserver --- stackit/internal/testutil/mockserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/testutil/mockserver.go b/stackit/internal/testutil/mockserver.go index 504690b09..30911e2f8 100644 --- a/stackit/internal/testutil/mockserver.go +++ b/stackit/internal/testutil/mockserver.go @@ -50,7 +50,7 @@ func (m *MockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.t.Fatalf("Error marshaling response body: %v", err) } w.Header().Set("content-type", "application/json") - w.Write(bs) + w.Write(bs) //nolint:errcheck } status := next.StatusCode if status == 0 { From bde77f8d8bc7807bd7204293cbe0d79a0064e503 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Thu, 19 Feb 2026 11:14:27 +0100 Subject: [PATCH 4/7] fix(lint) add explanation to ignore comment --- stackit/internal/testutil/mockserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackit/internal/testutil/mockserver.go b/stackit/internal/testutil/mockserver.go index 30911e2f8..1fdd8dd27 100644 --- a/stackit/internal/testutil/mockserver.go +++ b/stackit/internal/testutil/mockserver.go @@ -50,7 +50,7 @@ func (m *MockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.t.Fatalf("Error marshaling response body: %v", err) } w.Header().Set("content-type", "application/json") - w.Write(bs) //nolint:errcheck + w.Write(bs) //nolint:errcheck //test will fail when this happens } status := next.StatusCode if status == 0 { From f7458383c28c083e1f85ccc58bf80fb71e0ed44f Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Fri, 20 Feb 2026 10:47:56 +0100 Subject: [PATCH 5/7] feat(scf) save IDs before calling the waiter in Create also fix rabbitmq tests STACKITTPR-393 --- .../services/rabbitmq/rabbitmq_acc_test.go | 91 ++++++------------- .../services/scf/organization/resource.go | 19 +++- .../scf/organizationmanager/resource.go | 22 ++++- stackit/internal/services/scf/scf_acc_test.go | 68 ++++++++++++++ stackit/internal/testutil/mockserver.go | 9 ++ 5 files changed, 135 insertions(+), 74 deletions(-) diff --git a/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go b/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go index d6d984191..e01a2289b 100644 --- a/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go +++ b/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go @@ -246,9 +246,7 @@ func TestAccRabbitMQResource(t *testing.T) { } // Run apply for an instance and produce an error in the waiter. By erroring out state checks are not run in this step. -// The second step imports the resource and runs a state check to verify that the first step wrote the IDs, despite the error. -// When importing we append "-import" to the credential ID to verify that the import isn't overwriting the instance -// ID from the first step +// The second step refreshes the resource and verifies that the IDs are passed to the read function. func TestRabbitMQInstanceSavesIDsOnError(t *testing.T) { projectId := uuid.NewString() instanceId := uuid.NewString() @@ -294,16 +292,15 @@ resource "stackit_rabbitmq_instance" "instance" { { PreConfig: func() { s.Reset( - // respond to listing offerings offerings, - // initial post response testutil.MockResponse{ + Description: "create", ToJsonBody: rabbitmq.CreateInstanceResponse{ InstanceId: utils.Ptr(instanceId), }, }, - // failing waiter testutil.MockResponse{ + Description: "create waiter", ToJsonBody: rabbitmq.Instance{ Status: utils.Ptr(rabbitmq.INSTANCESTATUS_FAILED), }, @@ -316,49 +313,29 @@ resource "stackit_rabbitmq_instance" "instance" { { PreConfig: func() { s.Reset( - // read from import testutil.MockResponse{ - ToJsonBody: rabbitmq.Instance{ - Status: utils.Ptr(rabbitmq.INSTANCESTATUS_ACTIVE), - InstanceId: utils.Ptr(instanceId + "-import"), - PlanId: utils.Ptr(planId), + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s", projectId, instanceId) + if req.URL.Path != expected { + t.Errorf("expected request to %s, got %s", expected, req.URL.Path) + } + w.WriteHeader(http.StatusInternalServerError) }, }, - // list offerings in import - offerings, - // delete - testutil.MockResponse{StatusCode: http.StatusAccepted}, - // delete waiter - testutil.MockResponse{ - StatusCode: http.StatusGone, - }, + testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, ) }, - ImportStateCheck: func(states []*terraform.InstanceState) error { - if len(states) != 1 { - return fmt.Errorf("expected exactly one state to be imported, got %d", len(states)) - } - state := states[0] - if state.Attributes["instance_id"] != instanceId { - return fmt.Errorf("expected instance_id to be %s, got %s", instanceId, state.Attributes["instance_id"]) - } - if state.Attributes["project_id"] != projectId { - return fmt.Errorf("expected project_id to be %s, got %s", projectId, state.Attributes["project_id"]) - } - return nil - }, - ImportState: true, - ImportStateId: fmt.Sprintf("%s,%s", projectId, instanceId), - ResourceName: "stackit_rabbitmq_instance.instance", + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading instance.*"), }, }, }) } // Run apply for credentials and produce an error in the waiter. By erroring out state checks are not run in this step. -// The second step imports the resource and runs a state check to verify that the first step wrote the IDs, despite the error. -// When importing we append "-import" to the credential ID to verify that the import isn't overwriting the credential -// ID from the first step +// The second step refreshes the resource and verifies that the IDs are passed to the read function. func TestRabbitMQCredentialsSavesIDsOnError(t *testing.T) { var ( projectId = uuid.NewString() @@ -400,38 +377,22 @@ resource "stackit_rabbitmq_credential" "credential" { { PreConfig: func() { s.Reset( - // read from import testutil.MockResponse{ - ToJsonBody: rabbitmq.CredentialsResponse{ - Id: utils.Ptr(credentialId + "-import"), - Raw: &rabbitmq.RawCredentials{}, + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s/credentials/%s", projectId, instanceId, credentialId) + if req.URL.Path != expected { + t.Errorf("expected request to %s, got %s", expected, req.URL.Path) + } + w.WriteHeader(http.StatusInternalServerError) }, }, - // delete - testutil.MockResponse{StatusCode: http.StatusAccepted}, - // delete waiter - testutil.MockResponse{StatusCode: http.StatusGone}, + testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, ) }, - ImportStateCheck: func(states []*terraform.InstanceState) error { - if len(states) != 1 { - return fmt.Errorf("expected exactly one state to be imported, got %d", len(states)) - } - state := states[0] - if state.Attributes["instance_id"] != instanceId { - return fmt.Errorf("expected instance_id to be %s, got %s", instanceId, state.Attributes["instance_id"]) - } - if state.Attributes["project_id"] != projectId { - return fmt.Errorf("expected project_id to be %s, got %s", projectId, state.Attributes["project_id"]) - } - if state.Attributes["credential_id"] != credentialId { - return fmt.Errorf("expected credential_id to be %s, got %s", credentialId, state.Attributes["credential_id"]) - } - return nil - }, - ImportState: true, - ImportStateId: fmt.Sprintf("%s,%s,%s", projectId, instanceId, credentialId), - ResourceName: "stackit_rabbitmq_credential.credential", + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading credential.*"), }, }, }) diff --git a/stackit/internal/services/scf/organization/resource.go b/stackit/internal/services/scf/organization/resource.go index 4ae7f9b94..15c4dc3a1 100644 --- a/stackit/internal/services/scf/organization/resource.go +++ b/stackit/internal/services/scf/organization/resource.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" @@ -260,8 +259,18 @@ func (s *scfOrganizationResource) Create(ctx context.Context, request resource.C ctx = core.LogResponse(ctx) + if scfOrgCreateResponse.Guid == nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization", "API response did not include org ID") + return + } orgId := *scfOrgCreateResponse.Guid + ctx = utils.SetAndLogStateFields(ctx, &response.Diagnostics, &response.State, map[string]any{ + "project_id": projectId, + "region": region, + "org_id": orgId, + }) + // Apply the org quota if provided if quotaId != "" { applyOrgQuota, err := s.client.ApplyOrganizationQuota(ctx, projectId, region, orgId).ApplyOrganizationQuotaPayload( @@ -485,9 +494,11 @@ func (s *scfOrganizationResource) ImportState(ctx context.Context, request resou region := idParts[1] orgId := idParts[2] // Set the project id and organization id in the state - response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) - response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("region"), region)...) - response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("org_id"), orgId)...) + ctx = utils.SetAndLogStateFields(ctx, &response.Diagnostics, &response.State, map[string]any{ + "project_id": projectId, + "region": region, + "org_id": orgId, + }) tflog.Info(ctx, "Scf organization state imported") } diff --git a/stackit/internal/services/scf/organizationmanager/resource.go b/stackit/internal/services/scf/organizationmanager/resource.go index 027fe6c9e..ad8e9ae14 100644 --- a/stackit/internal/services/scf/organizationmanager/resource.go +++ b/stackit/internal/services/scf/organizationmanager/resource.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -239,6 +238,17 @@ func (s *scfOrganizationManagerResource) Create(ctx context.Context, request res ctx = core.LogResponse(ctx) + if scfOrgManagerCreateResponse.Guid == nil { + core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization manager", "API response does not contain user id") + } + userId := *scfOrgManagerCreateResponse.Guid + ctx = utils.SetAndLogStateFields(ctx, &response.Diagnostics, &response.State, map[string]any{ + "project_id": projectId, + "region": region, + "org_id": orgId, + "user_id": userId, + }) + err = mapFieldsCreate(scfOrgManagerCreateResponse, &model) if err != nil { core.LogAndAddError(ctx, &response.Diagnostics, "Error creating scf organization manager", fmt.Sprintf("Mapping fields: %v", err)) @@ -360,10 +370,12 @@ func (s *scfOrganizationManagerResource) ImportState(ctx context.Context, reques orgId := idParts[2] userId := idParts[3] // Set the project id, region organization id and user id in the state - response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) - response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("region"), region)...) - response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("org_id"), orgId)...) - response.Diagnostics.Append(response.State.SetAttribute(ctx, path.Root("user_id"), userId)...) + ctx = utils.SetAndLogStateFields(ctx, &response.Diagnostics, &response.State, map[string]any{ + "project_id": projectId, + "region": region, + "org_id": orgId, + "user_id": userId, + }) tflog.Info(ctx, "Scf organization manager state imported") } diff --git a/stackit/internal/services/scf/scf_acc_test.go b/stackit/internal/services/scf/scf_acc_test.go index 3003ba6d6..f0371232f 100644 --- a/stackit/internal/services/scf/scf_acc_test.go +++ b/stackit/internal/services/scf/scf_acc_test.go @@ -5,9 +5,12 @@ import ( _ "embed" "fmt" "maps" + "net/http" + "regexp" "strings" "testing" + "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/scf" "github.com/hashicorp/terraform-plugin-testing/config" @@ -409,6 +412,71 @@ func TestAccScfOrgMax(t *testing.T) { }) } +// Run apply and fail in the waiter. We expect that the IDs are saved in the state. +// Verify this in the second step by refreshing and checking the IDs in the URL. +func TestScfOrganizationSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + guid = uuid.NewString() + ) + const name = "scf-org-error-test" + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + default_region = "eu01" + scf_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_scf_organization" "org" { + project_id = "%s" + name = "%s" +} +`, s.Server.URL, projectId, name) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "create", + ToJsonBody: &scf.OrganizationCreateResponse{ + Guid: utils.Ptr(guid), + }, + }, + testutil.MockResponse{Description: "create waiter", StatusCode: http.StatusNotFound}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating scf organization.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/regions/%s/organizations/%s", projectId, region, guid) + if req.URL.Path != expected { + t.Errorf("Expected request to %s but got %s", expected, req.URL.Path) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete"}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusNotFound}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading scf organization.*"), + }, + }, + }) +} + func testAccCheckScfOrganizationDestroy(s *terraform.State) error { ctx := context.Background() var client *scf.APIClient diff --git a/stackit/internal/testutil/mockserver.go b/stackit/internal/testutil/mockserver.go index 1fdd8dd27..478adafb4 100644 --- a/stackit/internal/testutil/mockserver.go +++ b/stackit/internal/testutil/mockserver.go @@ -8,10 +8,15 @@ import ( "testing" ) +// MockResponse represents a single response that the MockServer will return for a request. +// If `Handler` is set, it will be used to handle the request and the other fields will be ignored. +// If `ToJsonBody` is set, it will be marshaled to JSON and returned as the response body with content-type application/json. +// If `StatusCode` is set, it will be used as the response status code. Otherwise, http.StatusOK will be used. type MockResponse struct { StatusCode int Description string ToJsonBody any + Handler http.HandlerFunc } var _ http.Handler = (*MockServer)(nil) @@ -44,6 +49,10 @@ func (m *MockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } next := m.responses[m.nextResponse] m.nextResponse++ + if next.Handler != nil { + next.Handler(w, r) + return + } if next.ToJsonBody != nil { bs, err := json.Marshal(next.ToJsonBody) if err != nil { From f36127360a5de751dab2760fb33f88b2cfecc8ec Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Fri, 20 Feb 2026 12:21:10 +0100 Subject: [PATCH 6/7] feat(opensearch) instance + credential, save IDs immediately after create STACKITTPR-388 --- .../opensearch/credential/resource.go | 15 +- .../services/opensearch/instance/resource.go | 18 ++- .../opensearch/opensearch_acc_test.go | 150 ++++++++++++++++++ 3 files changed, 173 insertions(+), 10 deletions(-) diff --git a/stackit/internal/services/opensearch/credential/resource.go b/stackit/internal/services/opensearch/credential/resource.go index 041718dd4..1510cd89a 100644 --- a/stackit/internal/services/opensearch/credential/resource.go +++ b/stackit/internal/services/opensearch/credential/resource.go @@ -15,7 +15,6 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -190,7 +189,11 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ return } credentialId := *credentialsResp.Id - ctx = tflog.SetField(ctx, "credential_id", credentialId) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "instance_id": instanceId, + "credential_id": credentialId, + }) waitResp, err := wait.CreateCredentialsWaitHandler(ctx, r.client, projectId, instanceId, credentialId).WaitWithContext(ctx) if err != nil { @@ -311,9 +314,11 @@ func (r *credentialResource) ImportState(ctx context.Context, req resource.Impor return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("credential_id"), idParts[2])...) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "instance_id": idParts[1], + "credential_id": idParts[2], + }) tflog.Info(ctx, "OpenSearch credential state imported") } diff --git a/stackit/internal/services/opensearch/instance/resource.go b/stackit/internal/services/opensearch/instance/resource.go index e23554952..844216c79 100644 --- a/stackit/internal/services/opensearch/instance/resource.go +++ b/stackit/internal/services/opensearch/instance/resource.go @@ -20,7 +20,6 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" - "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -366,9 +365,16 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques } ctx = core.LogResponse(ctx) - + if createResp.InstanceId == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "API response did not include instance ID") + } instanceId := *createResp.InstanceId - ctx = tflog.SetField(ctx, "instance_id", instanceId) + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "instance_id": instanceId, + }) + waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client, projectId, instanceId).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Instance creation waiting: %v", err)) @@ -557,8 +563,10 @@ func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportS return } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...) - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...) + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "instance_id": idParts[1], + }) tflog.Info(ctx, "OpenSearch instance state imported") } diff --git a/stackit/internal/services/opensearch/opensearch_acc_test.go b/stackit/internal/services/opensearch/opensearch_acc_test.go index 223b12cee..1c722ec90 100644 --- a/stackit/internal/services/opensearch/opensearch_acc_test.go +++ b/stackit/internal/services/opensearch/opensearch_acc_test.go @@ -3,9 +3,12 @@ package opensearch_test import ( "context" "fmt" + "net/http" + "regexp" "strings" "testing" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -235,6 +238,153 @@ func TestAccOpenSearchResource(t *testing.T) { }) } +func TestOpensearchInstanceSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + instanceId = uuid.NewString() + ) + const ( + name = "opensearch-instance-test" + version = "version" + planName = "plan-name" + ) + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + opensearch_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_opensearch_instance" "instance" { + project_id = "%s" + name = "%s" + version = "%s" + plan_name = "%s" +} +`, s.Server.URL, projectId, name, version, planName) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "offerings", + ToJsonBody: &opensearch.ListOfferingsResponse{ + Offerings: &[]opensearch.Offering{ + { + Name: utils.Ptr("offering-name"), + Version: utils.Ptr(version), + Plans: &[]opensearch.Plan{ + { + Id: utils.Ptr("plan-id"), + Name: utils.Ptr(planName), + }, + }, + }, + }, + }, + }, + testutil.MockResponse{ + Description: "create instance", + ToJsonBody: &opensearch.CreateInstanceResponse{ + InstanceId: utils.Ptr(instanceId), + }, + }, + testutil.MockResponse{Description: "failing waiter", StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating instance.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s", projectId, instanceId) + if req.URL.Path != expected { + t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete"}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading instance.*"), + }, + }, + }) +} + +func TestOpensearchCredentialSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + instanceId = uuid.NewString() + credentialId = uuid.NewString() + ) + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + opensearch_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_opensearch_credential" "credential" { + project_id = "%s" + instance_id = "%s" +} +`, s.Server.URL, projectId, instanceId) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "create credential", + ToJsonBody: &opensearch.CredentialsResponse{ + Id: utils.Ptr(credentialId), + }, + }, + testutil.MockResponse{Description: "create waiter", StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating credential.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s/credentials/%s", projectId, instanceId, credentialId) + if req.URL.Path != expected { + t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete"}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading credential.*"), + }, + }, + }) +} + func testAccCheckOpenSearchDestroy(s *terraform.State) error { ctx := context.Background() var client *opensearch.APIClient From 7267364be9eb83827105fb563b2d466c724d1ffe Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 24 Feb 2026 15:32:43 +0100 Subject: [PATCH 7/7] chore(opensearch) move SavesIDsOnError tests into new file, document - move opensearch, scf and rabbitmq SavesIDsOnError tests into new files - document SavesIDsOnError tests in CONTRIBUTION.md - add annotated example test to github docs --- .../docs/contribution-guide/package_test.go | 91 ++++++++++ CONTRIBUTION.md | 17 +- .../opensearch/opensearch_acc_test.go | 150 ---------------- .../services/opensearch/opensearch_test.go | 161 +++++++++++++++++ .../services/rabbitmq/rabbitmq_acc_test.go | 155 ----------------- .../services/rabbitmq/rabbitmq_test.go | 163 ++++++++++++++++++ stackit/internal/services/scf/scf_acc_test.go | 68 -------- stackit/internal/services/scf/scf_test.go | 77 +++++++++ 8 files changed, 508 insertions(+), 374 deletions(-) create mode 100644 .github/docs/contribution-guide/package_test.go create mode 100644 stackit/internal/services/opensearch/opensearch_test.go create mode 100644 stackit/internal/services/rabbitmq/rabbitmq_test.go create mode 100644 stackit/internal/services/scf/scf_test.go diff --git a/.github/docs/contribution-guide/package_test.go b/.github/docs/contribution-guide/package_test.go new file mode 100644 index 000000000..873a65379 --- /dev/null +++ b/.github/docs/contribution-guide/package_test.go @@ -0,0 +1,91 @@ +package foo + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +func TestFooSavesIDsOnError(t *testing.T) { + /* Setup code: + - define known values for attributes used in id + - create mock server + - define minimal tf config with custom endpoint pointing to mock server + */ + var ( + projectId = uuid.NewString() + barId = uuid.NewString() + ) + const region = "eu01" + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + foo_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_foo" "foo" { + project_id = "%s" +} +`, s.Server.URL, projectId) + + /* Test steps: + 1. Create resource with mocked backend + 2. Verify with a refresh, that IDs are saved to state + */ + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + /* Setup mock responses for create and waiter. + The create response succeeds and returns the barId, but the waiter fails with an error. + We can't check the state in this step, because the create returns early due to the waiter error. + TF won't execute any Checks of the TestStep if there is an error. + */ + s.Reset( + testutil.MockResponse{ + Description: "create foo", + ToJsonBody: &BarResponse{ + BarId: barId, + }, + }, + testutil.MockResponse{Description: "failing waiter", StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating foo.*"), + }, + { + PreConfig: func() { + /* Setup mock responses for refresh and delete. + The refresh response fails with an error, but we want to verify that the URL contains the correct IDs. + After the test TF will automatically destroy the resource. So we set up mocks to simulate a successful dlete. + */ + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/regions/%s/foo/%s", projectId, region, barId) + if req.URL.Path != expected { + t.Errorf("unexpected URL path: got %s, want %s", req.URL.Path, expected) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete"}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading foo.*"), + }, + }, + }) +} diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index c9fe41f77..c7312031c 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -84,7 +84,22 @@ If you want to onboard resources of a STACKIT service `foo` that was not yet in ``` 4. Create a utils package, for service `foo` it would be `stackit/internal/foo/utils`. Add a `ConfigureClient()` func and use it in your resource and datasource implementations. -https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/utils/util.go + https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/utils/util.go + +5. If the service `foo` uses async creation (you have to use a waiter after creating the resource), we want to save the + IDs as soon as possible to the state. Should the waiter time out, we'll still have the IDs in the state and allow + further usage of that resource. + + https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/resource.go + + The example in the contribution-guide linked above, does this by calling `utils.SetAndLogStateFields` before calling + the waiter. + + To test this we use terraforms acceptance tests in unit test mode. These tests are named `TestSavesIDsOnError`. + You can find an annotated example of such tests in: + + https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/resource.go + ### Local development diff --git a/stackit/internal/services/opensearch/opensearch_acc_test.go b/stackit/internal/services/opensearch/opensearch_acc_test.go index 1c722ec90..223b12cee 100644 --- a/stackit/internal/services/opensearch/opensearch_acc_test.go +++ b/stackit/internal/services/opensearch/opensearch_acc_test.go @@ -3,12 +3,9 @@ package opensearch_test import ( "context" "fmt" - "net/http" - "regexp" "strings" "testing" - "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -238,153 +235,6 @@ func TestAccOpenSearchResource(t *testing.T) { }) } -func TestOpensearchInstanceSavesIDsOnError(t *testing.T) { - var ( - projectId = uuid.NewString() - instanceId = uuid.NewString() - ) - const ( - name = "opensearch-instance-test" - version = "version" - planName = "plan-name" - ) - s := testutil.NewMockServer(t) - defer s.Server.Close() - tfConfig := fmt.Sprintf(` -provider "stackit" { - opensearch_custom_endpoint = "%s" - service_account_token = "mock-server-needs-no-auth" -} - -resource "stackit_opensearch_instance" "instance" { - project_id = "%s" - name = "%s" - version = "%s" - plan_name = "%s" -} -`, s.Server.URL, projectId, name, version, planName) - - resource.UnitTest(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "offerings", - ToJsonBody: &opensearch.ListOfferingsResponse{ - Offerings: &[]opensearch.Offering{ - { - Name: utils.Ptr("offering-name"), - Version: utils.Ptr(version), - Plans: &[]opensearch.Plan{ - { - Id: utils.Ptr("plan-id"), - Name: utils.Ptr(planName), - }, - }, - }, - }, - }, - }, - testutil.MockResponse{ - Description: "create instance", - ToJsonBody: &opensearch.CreateInstanceResponse{ - InstanceId: utils.Ptr(instanceId), - }, - }, - testutil.MockResponse{Description: "failing waiter", StatusCode: http.StatusInternalServerError}, - ) - }, - Config: tfConfig, - ExpectError: regexp.MustCompile("Error creating instance.*"), - }, - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "refresh", - Handler: func(w http.ResponseWriter, req *http.Request) { - expected := fmt.Sprintf("/v1/projects/%s/instances/%s", projectId, instanceId) - if req.URL.Path != expected { - t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest) - } - w.WriteHeader(http.StatusInternalServerError) - }, - }, - testutil.MockResponse{Description: "delete"}, - testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, - ) - }, - RefreshState: true, - ExpectError: regexp.MustCompile("Error reading instance.*"), - }, - }, - }) -} - -func TestOpensearchCredentialSavesIDsOnError(t *testing.T) { - var ( - projectId = uuid.NewString() - instanceId = uuid.NewString() - credentialId = uuid.NewString() - ) - s := testutil.NewMockServer(t) - defer s.Server.Close() - tfConfig := fmt.Sprintf(` -provider "stackit" { - opensearch_custom_endpoint = "%s" - service_account_token = "mock-server-needs-no-auth" -} - -resource "stackit_opensearch_credential" "credential" { - project_id = "%s" - instance_id = "%s" -} -`, s.Server.URL, projectId, instanceId) - - resource.UnitTest(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "create credential", - ToJsonBody: &opensearch.CredentialsResponse{ - Id: utils.Ptr(credentialId), - }, - }, - testutil.MockResponse{Description: "create waiter", StatusCode: http.StatusInternalServerError}, - ) - }, - Config: tfConfig, - ExpectError: regexp.MustCompile("Error creating credential.*"), - }, - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "refresh", - Handler: func(w http.ResponseWriter, req *http.Request) { - expected := fmt.Sprintf("/v1/projects/%s/instances/%s/credentials/%s", projectId, instanceId, credentialId) - if req.URL.Path != expected { - t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest) - } - w.WriteHeader(http.StatusInternalServerError) - }, - }, - testutil.MockResponse{Description: "delete"}, - testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, - ) - }, - RefreshState: true, - ExpectError: regexp.MustCompile("Error reading credential.*"), - }, - }, - }) -} - func testAccCheckOpenSearchDestroy(s *terraform.State) error { ctx := context.Background() var client *opensearch.APIClient diff --git a/stackit/internal/services/opensearch/opensearch_test.go b/stackit/internal/services/opensearch/opensearch_test.go new file mode 100644 index 000000000..1077ee667 --- /dev/null +++ b/stackit/internal/services/opensearch/opensearch_test.go @@ -0,0 +1,161 @@ +package opensearch + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/opensearch" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +func TestOpensearchInstanceSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + instanceId = uuid.NewString() + ) + const ( + name = "opensearch-instance-test" + version = "version" + planName = "plan-name" + ) + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + opensearch_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_opensearch_instance" "instance" { + project_id = "%s" + name = "%s" + version = "%s" + plan_name = "%s" +} +`, s.Server.URL, projectId, name, version, planName) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "offerings", + ToJsonBody: &opensearch.ListOfferingsResponse{ + Offerings: &[]opensearch.Offering{ + { + Name: utils.Ptr("offering-name"), + Version: utils.Ptr(version), + Plans: &[]opensearch.Plan{ + { + Id: utils.Ptr("plan-id"), + Name: utils.Ptr(planName), + }, + }, + }, + }, + }, + }, + testutil.MockResponse{ + Description: "create instance", + ToJsonBody: &opensearch.CreateInstanceResponse{ + InstanceId: utils.Ptr(instanceId), + }, + }, + testutil.MockResponse{Description: "failing waiter", StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating instance.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s", projectId, instanceId) + if req.URL.Path != expected { + t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete"}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading instance.*"), + }, + }, + }) +} + +func TestOpensearchCredentialSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + instanceId = uuid.NewString() + credentialId = uuid.NewString() + ) + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + opensearch_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_opensearch_credential" "credential" { + project_id = "%s" + instance_id = "%s" +} +`, s.Server.URL, projectId, instanceId) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "create credential", + ToJsonBody: &opensearch.CredentialsResponse{ + Id: utils.Ptr(credentialId), + }, + }, + testutil.MockResponse{Description: "create waiter", StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating credential.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s/credentials/%s", projectId, instanceId, credentialId) + if req.URL.Path != expected { + t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete"}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading credential.*"), + }, + }, + }) +} diff --git a/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go b/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go index e01a2289b..ff9d3f417 100644 --- a/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go +++ b/stackit/internal/services/rabbitmq/rabbitmq_acc_test.go @@ -3,12 +3,10 @@ package rabbitmq_test import ( "context" "fmt" - "net/http" "regexp" "strings" "testing" - "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stackitcloud/stackit-sdk-go/core/config" @@ -245,159 +243,6 @@ func TestAccRabbitMQResource(t *testing.T) { }) } -// Run apply for an instance and produce an error in the waiter. By erroring out state checks are not run in this step. -// The second step refreshes the resource and verifies that the IDs are passed to the read function. -func TestRabbitMQInstanceSavesIDsOnError(t *testing.T) { - projectId := uuid.NewString() - instanceId := uuid.NewString() - const ( - name = "instance-name" - planName = "plan-name" - planId = "plan-id" - version = "version" - ) - s := testutil.NewMockServer(t) - defer s.Server.Close() - tfConfig := fmt.Sprintf(` -provider "stackit" { - rabbitmq_custom_endpoint = "%s" - service_account_token = "mock-server-needs-no-auth" -} - -resource "stackit_rabbitmq_instance" "instance" { - project_id = "%s" - name = "%s" - plan_name = "%s" - version = "%s" -} -`, s.Server.URL, projectId, name, planName, version) - offerings := testutil.MockResponse{ - ToJsonBody: &rabbitmq.ListOfferingsResponse{ - Offerings: &[]rabbitmq.Offering{ - { - Version: utils.Ptr(version), - Plans: &[]rabbitmq.Plan{ - { - Name: utils.Ptr(planName), - Id: utils.Ptr(planId), - }, - }, - }, - }, - }, - } - resource.UnitTest(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - PreConfig: func() { - s.Reset( - offerings, - testutil.MockResponse{ - Description: "create", - ToJsonBody: rabbitmq.CreateInstanceResponse{ - InstanceId: utils.Ptr(instanceId), - }, - }, - testutil.MockResponse{ - Description: "create waiter", - ToJsonBody: rabbitmq.Instance{ - Status: utils.Ptr(rabbitmq.INSTANCESTATUS_FAILED), - }, - }, - ) - }, - Config: tfConfig, - ExpectError: regexp.MustCompile("Error creating instance.*"), - }, - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "refresh", - Handler: func(w http.ResponseWriter, req *http.Request) { - expected := fmt.Sprintf("/v1/projects/%s/instances/%s", projectId, instanceId) - if req.URL.Path != expected { - t.Errorf("expected request to %s, got %s", expected, req.URL.Path) - } - w.WriteHeader(http.StatusInternalServerError) - }, - }, - testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted}, - testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, - ) - }, - RefreshState: true, - ExpectError: regexp.MustCompile("Error reading instance.*"), - }, - }, - }) -} - -// Run apply for credentials and produce an error in the waiter. By erroring out state checks are not run in this step. -// The second step refreshes the resource and verifies that the IDs are passed to the read function. -func TestRabbitMQCredentialsSavesIDsOnError(t *testing.T) { - var ( - projectId = uuid.NewString() - instanceId = uuid.NewString() - credentialId = uuid.NewString() - ) - s := testutil.NewMockServer(t) - t.Cleanup(s.Server.Close) - tfConfig := fmt.Sprintf(` -provider "stackit" { - rabbitmq_custom_endpoint = "%s" - service_account_token = "mock-server-needs-no-auth" -} - -resource "stackit_rabbitmq_credential" "credential" { - project_id = "%s" - instance_id = "%s" -} -`, s.Server.URL, projectId, instanceId) - resource.UnitTest(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - PreConfig: func() { - s.Reset( - // initial post response - testutil.MockResponse{ - ToJsonBody: rabbitmq.CredentialsResponse{ - Id: utils.Ptr(credentialId), - }, - }, - // failing waiter - testutil.MockResponse{StatusCode: http.StatusInternalServerError}, - ) - }, - Config: tfConfig, - ExpectError: regexp.MustCompile("Error creating credential.*"), - }, - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "refresh", - Handler: func(w http.ResponseWriter, req *http.Request) { - expected := fmt.Sprintf("/v1/projects/%s/instances/%s/credentials/%s", projectId, instanceId, credentialId) - if req.URL.Path != expected { - t.Errorf("expected request to %s, got %s", expected, req.URL.Path) - } - w.WriteHeader(http.StatusInternalServerError) - }, - }, - testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted}, - testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, - ) - }, - RefreshState: true, - ExpectError: regexp.MustCompile("Error reading credential.*"), - }, - }, - }) -} - func testAccCheckRabbitMQDestroy(s *terraform.State) error { ctx := context.Background() var client *rabbitmq.APIClient diff --git a/stackit/internal/services/rabbitmq/rabbitmq_test.go b/stackit/internal/services/rabbitmq/rabbitmq_test.go new file mode 100644 index 000000000..4afc5aa9f --- /dev/null +++ b/stackit/internal/services/rabbitmq/rabbitmq_test.go @@ -0,0 +1,163 @@ +package rabbitmq + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +func TestRabbitMQInstanceSavesIDsOnError(t *testing.T) { + projectId := uuid.NewString() + instanceId := uuid.NewString() + const ( + name = "instance-name" + planName = "plan-name" + planId = "plan-id" + version = "version" + ) + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + rabbitmq_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_rabbitmq_instance" "instance" { + project_id = "%s" + name = "%s" + plan_name = "%s" + version = "%s" +} +`, s.Server.URL, projectId, name, planName, version) + offerings := testutil.MockResponse{ + ToJsonBody: &rabbitmq.ListOfferingsResponse{ + Offerings: &[]rabbitmq.Offering{ + { + Version: utils.Ptr(version), + Plans: &[]rabbitmq.Plan{ + { + Name: utils.Ptr(planName), + Id: utils.Ptr(planId), + }, + }, + }, + }, + }, + } + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + offerings, + testutil.MockResponse{ + Description: "create", + ToJsonBody: rabbitmq.CreateInstanceResponse{ + InstanceId: utils.Ptr(instanceId), + }, + }, + testutil.MockResponse{ + Description: "create waiter", + ToJsonBody: rabbitmq.Instance{ + Status: utils.Ptr(rabbitmq.INSTANCESTATUS_FAILED), + }, + }, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating instance.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s", projectId, instanceId) + if req.URL.Path != expected { + t.Errorf("expected request to %s, got %s", expected, req.URL.Path) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading instance.*"), + }, + }, + }) +} + +func TestRabbitMQCredentialsSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + instanceId = uuid.NewString() + credentialId = uuid.NewString() + ) + s := testutil.NewMockServer(t) + t.Cleanup(s.Server.Close) + tfConfig := fmt.Sprintf(` +provider "stackit" { + rabbitmq_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_rabbitmq_credential" "credential" { + project_id = "%s" + instance_id = "%s" +} +`, s.Server.URL, projectId, instanceId) + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + // initial post response + testutil.MockResponse{ + ToJsonBody: rabbitmq.CredentialsResponse{ + Id: utils.Ptr(credentialId), + }, + }, + // failing waiter + testutil.MockResponse{StatusCode: http.StatusInternalServerError}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating credential.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/instances/%s/credentials/%s", projectId, instanceId, credentialId) + if req.URL.Path != expected { + t.Errorf("expected request to %s, got %s", expected, req.URL.Path) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading credential.*"), + }, + }, + }) +} diff --git a/stackit/internal/services/scf/scf_acc_test.go b/stackit/internal/services/scf/scf_acc_test.go index f0371232f..3003ba6d6 100644 --- a/stackit/internal/services/scf/scf_acc_test.go +++ b/stackit/internal/services/scf/scf_acc_test.go @@ -5,12 +5,9 @@ import ( _ "embed" "fmt" "maps" - "net/http" - "regexp" "strings" "testing" - "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/scf" "github.com/hashicorp/terraform-plugin-testing/config" @@ -412,71 +409,6 @@ func TestAccScfOrgMax(t *testing.T) { }) } -// Run apply and fail in the waiter. We expect that the IDs are saved in the state. -// Verify this in the second step by refreshing and checking the IDs in the URL. -func TestScfOrganizationSavesIDsOnError(t *testing.T) { - var ( - projectId = uuid.NewString() - guid = uuid.NewString() - ) - const name = "scf-org-error-test" - s := testutil.NewMockServer(t) - defer s.Server.Close() - tfConfig := fmt.Sprintf(` -provider "stackit" { - default_region = "eu01" - scf_custom_endpoint = "%s" - service_account_token = "mock-server-needs-no-auth" -} - -resource "stackit_scf_organization" "org" { - project_id = "%s" - name = "%s" -} -`, s.Server.URL, projectId, name) - - resource.UnitTest(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "create", - ToJsonBody: &scf.OrganizationCreateResponse{ - Guid: utils.Ptr(guid), - }, - }, - testutil.MockResponse{Description: "create waiter", StatusCode: http.StatusNotFound}, - ) - }, - Config: tfConfig, - ExpectError: regexp.MustCompile("Error creating scf organization.*"), - }, - { - PreConfig: func() { - s.Reset( - testutil.MockResponse{ - Description: "refresh", - Handler: func(w http.ResponseWriter, req *http.Request) { - expected := fmt.Sprintf("/v1/projects/%s/regions/%s/organizations/%s", projectId, region, guid) - if req.URL.Path != expected { - t.Errorf("Expected request to %s but got %s", expected, req.URL.Path) - } - w.WriteHeader(http.StatusInternalServerError) - }, - }, - testutil.MockResponse{Description: "delete"}, - testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusNotFound}, - ) - }, - RefreshState: true, - ExpectError: regexp.MustCompile("Error reading scf organization.*"), - }, - }, - }) -} - func testAccCheckScfOrganizationDestroy(s *terraform.State) error { ctx := context.Background() var client *scf.APIClient diff --git a/stackit/internal/services/scf/scf_test.go b/stackit/internal/services/scf/scf_test.go new file mode 100644 index 000000000..b1fcb12c3 --- /dev/null +++ b/stackit/internal/services/scf/scf_test.go @@ -0,0 +1,77 @@ +package scf + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/scf" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +func TestScfOrganizationSavesIDsOnError(t *testing.T) { + var ( + projectId = uuid.NewString() + guid = uuid.NewString() + ) + const name = "scf-org-error-test" + s := testutil.NewMockServer(t) + defer s.Server.Close() + tfConfig := fmt.Sprintf(` +provider "stackit" { + default_region = "eu01" + scf_custom_endpoint = "%s" + service_account_token = "mock-server-needs-no-auth" +} + +resource "stackit_scf_organization" "org" { + project_id = "%s" + name = "%s" +} +`, s.Server.URL, projectId, name) + + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "create", + ToJsonBody: &scf.OrganizationCreateResponse{ + Guid: utils.Ptr(guid), + }, + }, + testutil.MockResponse{Description: "create waiter", StatusCode: http.StatusNotFound}, + ) + }, + Config: tfConfig, + ExpectError: regexp.MustCompile("Error creating scf organization.*"), + }, + { + PreConfig: func() { + s.Reset( + testutil.MockResponse{ + Description: "refresh", + Handler: func(w http.ResponseWriter, req *http.Request) { + expected := fmt.Sprintf("/v1/projects/%s/regions/%s/organizations/%s", projectId, region, guid) + if req.URL.Path != expected { + t.Errorf("Expected request to %s but got %s", expected, req.URL.Path) + } + w.WriteHeader(http.StatusInternalServerError) + }, + }, + testutil.MockResponse{Description: "delete"}, + testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusNotFound}, + ) + }, + RefreshState: true, + ExpectError: regexp.MustCompile("Error reading scf organization.*"), + }, + }, + }) +}