Skip to content

Commit c4664e9

Browse files
fix(rabbitmq): Store IDs immediately after provisioning (#1237)
* fix(rabbitmq): Store IDs immediately after provisioning STACKITTPR-390 * chore(rabbitmq) write tests for saving IDs on create error * fix(lint) ignore write error in mockserver * fix(lint) add explanation to ignore comment
1 parent 817d96c commit c4664e9

4 files changed

Lines changed: 297 additions & 9 deletions

File tree

stackit/internal/services/rabbitmq/credential/resource.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
1616
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
1717

18-
"github.com/hashicorp/terraform-plugin-framework/path"
1918
"github.com/hashicorp/terraform-plugin-framework/resource"
2019
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
2120
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
@@ -204,7 +203,14 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ
204203
return
205204
}
206205
credentialId := *credentialsResp.Id
207-
ctx = tflog.SetField(ctx, "credential_id", credentialId)
206+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
207+
"project_id": projectId,
208+
"instance_id": instanceId,
209+
"credential_id": credentialId,
210+
})
211+
if resp.Diagnostics.HasError() {
212+
return
213+
}
208214

209215
waitResp, err := wait.CreateCredentialsWaitHandler(ctx, r.client, projectId, instanceId, credentialId).WaitWithContext(ctx)
210216
if err != nil {
@@ -325,9 +331,14 @@ func (r *credentialResource) ImportState(ctx context.Context, req resource.Impor
325331
return
326332
}
327333

328-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
329-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...)
330-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("credential_id"), idParts[2])...)
334+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
335+
"project_id": idParts[0],
336+
"instance_id": idParts[1],
337+
"credential_id": idParts[2],
338+
})
339+
if resp.Diagnostics.HasError() {
340+
return
341+
}
331342
tflog.Info(ctx, "RabbitMQ credential state imported")
332343
}
333344

stackit/internal/services/rabbitmq/instance/resource.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
2020
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
2121

22-
"github.com/hashicorp/terraform-plugin-framework/path"
2322
"github.com/hashicorp/terraform-plugin-framework/resource"
2423
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
2524
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
@@ -363,8 +362,20 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques
363362

364363
ctx = core.LogResponse(ctx)
365364

365+
if createResp.InstanceId == nil {
366+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "API response did not include instance ID")
367+
return
368+
}
369+
366370
instanceId := *createResp.InstanceId
367-
ctx = tflog.SetField(ctx, "instance_id", instanceId)
371+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
372+
"project_id": projectId,
373+
"instance_id": instanceId,
374+
})
375+
if resp.Diagnostics.HasError() {
376+
return
377+
}
378+
368379
waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client, projectId, instanceId).WaitWithContext(ctx)
369380
if err != nil {
370381
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
554565
return
555566
}
556567

557-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
558-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...)
568+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
569+
"project_id": idParts[0],
570+
"instance_id": idParts[1],
571+
})
572+
if resp.Diagnostics.HasError() {
573+
return
574+
}
559575
tflog.Info(ctx, "RabbitMQ instance state imported")
560576
}
561577

stackit/internal/services/rabbitmq/rabbitmq_acc_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package rabbitmq_test
33
import (
44
"context"
55
"fmt"
6+
"net/http"
67
"regexp"
78
"strings"
89
"testing"
910

11+
"github.com/google/uuid"
1012
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
1113
"github.com/hashicorp/terraform-plugin-testing/terraform"
1214
"github.com/stackitcloud/stackit-sdk-go/core/config"
@@ -243,6 +245,198 @@ func TestAccRabbitMQResource(t *testing.T) {
243245
})
244246
}
245247

248+
// Run apply for an instance and produce an error in the waiter. By erroring out state checks are not run in this step.
249+
// The second step imports the resource and runs a state check to verify that the first step wrote the IDs, despite the error.
250+
// When importing we append "-import" to the credential ID to verify that the import isn't overwriting the instance
251+
// ID from the first step
252+
func TestRabbitMQInstanceSavesIDsOnError(t *testing.T) {
253+
projectId := uuid.NewString()
254+
instanceId := uuid.NewString()
255+
const (
256+
name = "instance-name"
257+
planName = "plan-name"
258+
planId = "plan-id"
259+
version = "version"
260+
)
261+
s := testutil.NewMockServer(t)
262+
defer s.Server.Close()
263+
tfConfig := fmt.Sprintf(`
264+
provider "stackit" {
265+
rabbitmq_custom_endpoint = "%s"
266+
service_account_token = "mock-server-needs-no-auth"
267+
}
268+
269+
resource "stackit_rabbitmq_instance" "instance" {
270+
project_id = "%s"
271+
name = "%s"
272+
plan_name = "%s"
273+
version = "%s"
274+
}
275+
`, s.Server.URL, projectId, name, planName, version)
276+
offerings := testutil.MockResponse{
277+
ToJsonBody: &rabbitmq.ListOfferingsResponse{
278+
Offerings: &[]rabbitmq.Offering{
279+
{
280+
Version: utils.Ptr(version),
281+
Plans: &[]rabbitmq.Plan{
282+
{
283+
Name: utils.Ptr(planName),
284+
Id: utils.Ptr(planId),
285+
},
286+
},
287+
},
288+
},
289+
},
290+
}
291+
resource.UnitTest(t, resource.TestCase{
292+
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
293+
Steps: []resource.TestStep{
294+
{
295+
PreConfig: func() {
296+
s.Reset(
297+
// respond to listing offerings
298+
offerings,
299+
// initial post response
300+
testutil.MockResponse{
301+
ToJsonBody: rabbitmq.CreateInstanceResponse{
302+
InstanceId: utils.Ptr(instanceId),
303+
},
304+
},
305+
// failing waiter
306+
testutil.MockResponse{
307+
ToJsonBody: rabbitmq.Instance{
308+
Status: utils.Ptr(rabbitmq.INSTANCESTATUS_FAILED),
309+
},
310+
},
311+
)
312+
},
313+
Config: tfConfig,
314+
ExpectError: regexp.MustCompile("Error creating instance.*"),
315+
},
316+
{
317+
PreConfig: func() {
318+
s.Reset(
319+
// read from import
320+
testutil.MockResponse{
321+
ToJsonBody: rabbitmq.Instance{
322+
Status: utils.Ptr(rabbitmq.INSTANCESTATUS_ACTIVE),
323+
InstanceId: utils.Ptr(instanceId + "-import"),
324+
PlanId: utils.Ptr(planId),
325+
},
326+
},
327+
// list offerings in import
328+
offerings,
329+
// delete
330+
testutil.MockResponse{StatusCode: http.StatusAccepted},
331+
// delete waiter
332+
testutil.MockResponse{
333+
StatusCode: http.StatusGone,
334+
},
335+
)
336+
},
337+
ImportStateCheck: func(states []*terraform.InstanceState) error {
338+
if len(states) != 1 {
339+
return fmt.Errorf("expected exactly one state to be imported, got %d", len(states))
340+
}
341+
state := states[0]
342+
if state.Attributes["instance_id"] != instanceId {
343+
return fmt.Errorf("expected instance_id to be %s, got %s", instanceId, state.Attributes["instance_id"])
344+
}
345+
if state.Attributes["project_id"] != projectId {
346+
return fmt.Errorf("expected project_id to be %s, got %s", projectId, state.Attributes["project_id"])
347+
}
348+
return nil
349+
},
350+
ImportState: true,
351+
ImportStateId: fmt.Sprintf("%s,%s", projectId, instanceId),
352+
ResourceName: "stackit_rabbitmq_instance.instance",
353+
},
354+
},
355+
})
356+
}
357+
358+
// Run apply for credentials and produce an error in the waiter. By erroring out state checks are not run in this step.
359+
// The second step imports the resource and runs a state check to verify that the first step wrote the IDs, despite the error.
360+
// When importing we append "-import" to the credential ID to verify that the import isn't overwriting the credential
361+
// ID from the first step
362+
func TestRabbitMQCredentialsSavesIDsOnError(t *testing.T) {
363+
var (
364+
projectId = uuid.NewString()
365+
instanceId = uuid.NewString()
366+
credentialId = uuid.NewString()
367+
)
368+
s := testutil.NewMockServer(t)
369+
t.Cleanup(s.Server.Close)
370+
tfConfig := fmt.Sprintf(`
371+
provider "stackit" {
372+
rabbitmq_custom_endpoint = "%s"
373+
service_account_token = "mock-server-needs-no-auth"
374+
}
375+
376+
resource "stackit_rabbitmq_credential" "credential" {
377+
project_id = "%s"
378+
instance_id = "%s"
379+
}
380+
`, s.Server.URL, projectId, instanceId)
381+
resource.UnitTest(t, resource.TestCase{
382+
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
383+
Steps: []resource.TestStep{
384+
{
385+
PreConfig: func() {
386+
s.Reset(
387+
// initial post response
388+
testutil.MockResponse{
389+
ToJsonBody: rabbitmq.CredentialsResponse{
390+
Id: utils.Ptr(credentialId),
391+
},
392+
},
393+
// failing waiter
394+
testutil.MockResponse{StatusCode: http.StatusInternalServerError},
395+
)
396+
},
397+
Config: tfConfig,
398+
ExpectError: regexp.MustCompile("Error creating credential.*"),
399+
},
400+
{
401+
PreConfig: func() {
402+
s.Reset(
403+
// read from import
404+
testutil.MockResponse{
405+
ToJsonBody: rabbitmq.CredentialsResponse{
406+
Id: utils.Ptr(credentialId + "-import"),
407+
Raw: &rabbitmq.RawCredentials{},
408+
},
409+
},
410+
// delete
411+
testutil.MockResponse{StatusCode: http.StatusAccepted},
412+
// delete waiter
413+
testutil.MockResponse{StatusCode: http.StatusGone},
414+
)
415+
},
416+
ImportStateCheck: func(states []*terraform.InstanceState) error {
417+
if len(states) != 1 {
418+
return fmt.Errorf("expected exactly one state to be imported, got %d", len(states))
419+
}
420+
state := states[0]
421+
if state.Attributes["instance_id"] != instanceId {
422+
return fmt.Errorf("expected instance_id to be %s, got %s", instanceId, state.Attributes["instance_id"])
423+
}
424+
if state.Attributes["project_id"] != projectId {
425+
return fmt.Errorf("expected project_id to be %s, got %s", projectId, state.Attributes["project_id"])
426+
}
427+
if state.Attributes["credential_id"] != credentialId {
428+
return fmt.Errorf("expected credential_id to be %s, got %s", credentialId, state.Attributes["credential_id"])
429+
}
430+
return nil
431+
},
432+
ImportState: true,
433+
ImportStateId: fmt.Sprintf("%s,%s,%s", projectId, instanceId, credentialId),
434+
ResourceName: "stackit_rabbitmq_credential.credential",
435+
},
436+
},
437+
})
438+
}
439+
246440
func testAccCheckRabbitMQDestroy(s *terraform.State) error {
247441
ctx := context.Background()
248442
var client *rabbitmq.APIClient
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package testutil
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync"
8+
"testing"
9+
)
10+
11+
type MockResponse struct {
12+
StatusCode int
13+
Description string
14+
ToJsonBody any
15+
}
16+
17+
var _ http.Handler = (*MockServer)(nil)
18+
19+
type MockServer struct {
20+
mu sync.Mutex
21+
nextResponse int
22+
responses []MockResponse
23+
Server *httptest.Server
24+
t *testing.T
25+
}
26+
27+
// NewMockServer creates a new simple mock server that returns `responses` in order for each request.
28+
// Use the `Reset` method to reset the response order and set new responses.
29+
func NewMockServer(t *testing.T, responses ...MockResponse) *MockServer {
30+
mock := &MockServer{
31+
nextResponse: 0,
32+
responses: responses,
33+
t: t,
34+
}
35+
mock.Server = httptest.NewServer(mock)
36+
return mock
37+
}
38+
39+
func (m *MockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
40+
m.mu.Lock()
41+
defer m.mu.Unlock()
42+
if m.nextResponse >= len(m.responses) {
43+
m.t.Fatalf("No more responses left in the mock server for request: %v", r)
44+
}
45+
next := m.responses[m.nextResponse]
46+
m.nextResponse++
47+
if next.ToJsonBody != nil {
48+
bs, err := json.Marshal(next.ToJsonBody)
49+
if err != nil {
50+
m.t.Fatalf("Error marshaling response body: %v", err)
51+
}
52+
w.Header().Set("content-type", "application/json")
53+
w.Write(bs) //nolint:errcheck //test will fail when this happens
54+
}
55+
status := next.StatusCode
56+
if status == 0 {
57+
status = http.StatusOK
58+
}
59+
w.WriteHeader(status)
60+
}
61+
62+
func (m *MockServer) Reset(responses ...MockResponse) {
63+
m.mu.Lock()
64+
defer m.mu.Unlock()
65+
m.nextResponse = 0
66+
m.responses = responses
67+
}

0 commit comments

Comments
 (0)