From 243f83123c0018c25ee454eae9b05dda3e3f4a94 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 17 Feb 2026 17:39:20 +0100 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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 {