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 {