Skip to content

Commit 18e0fe0

Browse files
feat(opensearch) instance + credential, save IDs immediately after cr… (#1246)
* 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 * feat(scf) save IDs before calling the waiter in Create also fix rabbitmq tests STACKITTPR-393 * feat(opensearch) instance + credential, save IDs immediately after create STACKITTPR-388 * 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 * Update .github/docs/contribution-guide/package_test.go Co-authored-by: Ruben Hönle <Ruben.Hoenle@stackit.cloud> --------- Co-authored-by: Ruben Hönle <Ruben.Hoenle@stackit.cloud>
1 parent cf68327 commit 18e0fe0

File tree

9 files changed

+531
-234
lines changed

9 files changed

+531
-234
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package foo
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"regexp"
7+
"testing"
8+
9+
"github.com/google/uuid"
10+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
11+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
12+
)
13+
14+
func TestFooSavesIDsOnError(t *testing.T) {
15+
/* Setup code:
16+
- define known values for attributes used in id
17+
- create mock server
18+
- define minimal tf config with custom endpoint pointing to mock server
19+
*/
20+
var (
21+
projectId = uuid.NewString()
22+
barId = uuid.NewString()
23+
)
24+
const region = "eu01"
25+
s := testutil.NewMockServer(t)
26+
defer s.Server.Close()
27+
tfConfig := fmt.Sprintf(`
28+
provider "stackit" {
29+
foo_custom_endpoint = "%s"
30+
service_account_token = "mock-server-needs-no-auth"
31+
}
32+
33+
resource "stackit_foo" "foo" {
34+
project_id = "%s"
35+
}
36+
`, s.Server.URL, projectId)
37+
38+
/* Test steps:
39+
1. Create resource with mocked backend
40+
2. Verify with a refresh, that IDs are saved to state
41+
*/
42+
resource.UnitTest(t, resource.TestCase{
43+
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
44+
Steps: []resource.TestStep{
45+
{
46+
PreConfig: func() {
47+
/* Setup mock responses for create and waiter.
48+
The create response succeeds and returns the barId, but the waiter fails with an error.
49+
We can't check the state in this step, because the create returns early due to the waiter error.
50+
TF won't execute any Checks of the TestStep if there is an error.
51+
*/
52+
s.Reset(
53+
testutil.MockResponse{
54+
Description: "create foo",
55+
ToJsonBody: &BarResponse{
56+
BarId: barId,
57+
},
58+
},
59+
testutil.MockResponse{Description: "failing waiter", StatusCode: http.StatusInternalServerError},
60+
)
61+
},
62+
Config: tfConfig,
63+
ExpectError: regexp.MustCompile("Error creating foo.*"),
64+
},
65+
{
66+
PreConfig: func() {
67+
/* Setup mock responses for refresh and delete.
68+
The refresh response fails with an error, but we want to verify that the URL contains the correct IDs.
69+
After the test TF will automatically destroy the resource. So we set up mocks to simulate a successful delete.
70+
*/
71+
s.Reset(
72+
testutil.MockResponse{
73+
Description: "refresh",
74+
Handler: func(w http.ResponseWriter, req *http.Request) {
75+
expected := fmt.Sprintf("/v1/projects/%s/regions/%s/foo/%s", projectId, region, barId)
76+
if req.URL.Path != expected {
77+
t.Errorf("unexpected URL path: got %s, want %s", req.URL.Path, expected)
78+
}
79+
w.WriteHeader(http.StatusInternalServerError)
80+
},
81+
},
82+
testutil.MockResponse{Description: "delete"},
83+
testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone},
84+
)
85+
},
86+
RefreshState: true,
87+
ExpectError: regexp.MustCompile("Error reading foo.*"),
88+
},
89+
},
90+
})
91+
}

CONTRIBUTION.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,22 @@ If you want to onboard resources of a STACKIT service `foo` that was not yet in
8484
```
8585
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.
8686

87-
https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/utils/util.go
87+
https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/utils/util.go
88+
89+
5. If the service `foo` uses async creation (you have to use a waiter after creating the resource), we want to save the
90+
IDs as soon as possible to the state. Should the waiter time out, we'll still have the IDs in the state and allow
91+
further usage of that resource.
92+
93+
https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/resource.go
94+
95+
The example in the contribution-guide linked above, does this by calling `utils.SetAndLogStateFields` before calling
96+
the waiter.
97+
98+
To test this we use terraforms acceptance tests in unit test mode. These tests are named `Test<RESOURCE>SavesIDsOnError`.
99+
You can find an annotated example of such tests in:
100+
101+
https://github.com/stackitcloud/terraform-provider-stackit/blob/main/.github/docs/contribution-guide/resource.go
102+
88103

89104
### Local development
90105

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

Lines changed: 10 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"
@@ -190,7 +189,11 @@ func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequ
190189
return
191190
}
192191
credentialId := *credentialsResp.Id
193-
ctx = tflog.SetField(ctx, "credential_id", credentialId)
192+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
193+
"project_id": projectId,
194+
"instance_id": instanceId,
195+
"credential_id": credentialId,
196+
})
194197

195198
waitResp, err := wait.CreateCredentialsWaitHandler(ctx, r.client, projectId, instanceId, credentialId).WaitWithContext(ctx)
196199
if err != nil {
@@ -311,9 +314,11 @@ func (r *credentialResource) ImportState(ctx context.Context, req resource.Impor
311314
return
312315
}
313316

314-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
315-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...)
316-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("credential_id"), idParts[2])...)
317+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
318+
"project_id": idParts[0],
319+
"instance_id": idParts[1],
320+
"credential_id": idParts[2],
321+
})
317322
tflog.Info(ctx, "OpenSearch credential state imported")
318323
}
319324

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

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

23-
"github.com/hashicorp/terraform-plugin-framework/path"
2423
"github.com/hashicorp/terraform-plugin-framework/resource"
2524
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
2625
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
@@ -366,9 +365,16 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques
366365
}
367366

368367
ctx = core.LogResponse(ctx)
369-
368+
if createResp.InstanceId == nil {
369+
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "API response did not include instance ID")
370+
}
370371
instanceId := *createResp.InstanceId
371-
ctx = tflog.SetField(ctx, "instance_id", instanceId)
372+
373+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
374+
"project_id": projectId,
375+
"instance_id": instanceId,
376+
})
377+
372378
waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client, projectId, instanceId).WaitWithContext(ctx)
373379
if err != nil {
374380
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
557563
return
558564
}
559565

560-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
561-
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[1])...)
566+
ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
567+
"project_id": idParts[0],
568+
"instance_id": idParts[1],
569+
})
562570
tflog.Info(ctx, "OpenSearch instance state imported")
563571
}
564572

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package opensearch
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"regexp"
7+
"testing"
8+
9+
"github.com/google/uuid"
10+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
11+
"github.com/stackitcloud/stackit-sdk-go/core/utils"
12+
"github.com/stackitcloud/stackit-sdk-go/services/opensearch"
13+
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
14+
)
15+
16+
func TestOpensearchInstanceSavesIDsOnError(t *testing.T) {
17+
var (
18+
projectId = uuid.NewString()
19+
instanceId = uuid.NewString()
20+
)
21+
const (
22+
name = "opensearch-instance-test"
23+
version = "version"
24+
planName = "plan-name"
25+
)
26+
s := testutil.NewMockServer(t)
27+
defer s.Server.Close()
28+
tfConfig := fmt.Sprintf(`
29+
provider "stackit" {
30+
opensearch_custom_endpoint = "%s"
31+
service_account_token = "mock-server-needs-no-auth"
32+
}
33+
34+
resource "stackit_opensearch_instance" "instance" {
35+
project_id = "%s"
36+
name = "%s"
37+
version = "%s"
38+
plan_name = "%s"
39+
}
40+
`, s.Server.URL, projectId, name, version, planName)
41+
42+
resource.UnitTest(t, resource.TestCase{
43+
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
44+
Steps: []resource.TestStep{
45+
{
46+
PreConfig: func() {
47+
s.Reset(
48+
testutil.MockResponse{
49+
Description: "offerings",
50+
ToJsonBody: &opensearch.ListOfferingsResponse{
51+
Offerings: &[]opensearch.Offering{
52+
{
53+
Name: utils.Ptr("offering-name"),
54+
Version: utils.Ptr(version),
55+
Plans: &[]opensearch.Plan{
56+
{
57+
Id: utils.Ptr("plan-id"),
58+
Name: utils.Ptr(planName),
59+
},
60+
},
61+
},
62+
},
63+
},
64+
},
65+
testutil.MockResponse{
66+
Description: "create instance",
67+
ToJsonBody: &opensearch.CreateInstanceResponse{
68+
InstanceId: utils.Ptr(instanceId),
69+
},
70+
},
71+
testutil.MockResponse{Description: "failing waiter", StatusCode: http.StatusInternalServerError},
72+
)
73+
},
74+
Config: tfConfig,
75+
ExpectError: regexp.MustCompile("Error creating instance.*"),
76+
},
77+
{
78+
PreConfig: func() {
79+
s.Reset(
80+
testutil.MockResponse{
81+
Description: "refresh",
82+
Handler: func(w http.ResponseWriter, req *http.Request) {
83+
expected := fmt.Sprintf("/v1/projects/%s/instances/%s", projectId, instanceId)
84+
if req.URL.Path != expected {
85+
t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest)
86+
}
87+
w.WriteHeader(http.StatusInternalServerError)
88+
},
89+
},
90+
testutil.MockResponse{Description: "delete"},
91+
testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone},
92+
)
93+
},
94+
RefreshState: true,
95+
ExpectError: regexp.MustCompile("Error reading instance.*"),
96+
},
97+
},
98+
})
99+
}
100+
101+
func TestOpensearchCredentialSavesIDsOnError(t *testing.T) {
102+
var (
103+
projectId = uuid.NewString()
104+
instanceId = uuid.NewString()
105+
credentialId = uuid.NewString()
106+
)
107+
s := testutil.NewMockServer(t)
108+
defer s.Server.Close()
109+
tfConfig := fmt.Sprintf(`
110+
provider "stackit" {
111+
opensearch_custom_endpoint = "%s"
112+
service_account_token = "mock-server-needs-no-auth"
113+
}
114+
115+
resource "stackit_opensearch_credential" "credential" {
116+
project_id = "%s"
117+
instance_id = "%s"
118+
}
119+
`, s.Server.URL, projectId, instanceId)
120+
121+
resource.UnitTest(t, resource.TestCase{
122+
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
123+
Steps: []resource.TestStep{
124+
{
125+
PreConfig: func() {
126+
s.Reset(
127+
testutil.MockResponse{
128+
Description: "create credential",
129+
ToJsonBody: &opensearch.CredentialsResponse{
130+
Id: utils.Ptr(credentialId),
131+
},
132+
},
133+
testutil.MockResponse{Description: "create waiter", StatusCode: http.StatusInternalServerError},
134+
)
135+
},
136+
Config: tfConfig,
137+
ExpectError: regexp.MustCompile("Error creating credential.*"),
138+
},
139+
{
140+
PreConfig: func() {
141+
s.Reset(
142+
testutil.MockResponse{
143+
Description: "refresh",
144+
Handler: func(w http.ResponseWriter, req *http.Request) {
145+
expected := fmt.Sprintf("/v1/projects/%s/instances/%s/credentials/%s", projectId, instanceId, credentialId)
146+
if req.URL.Path != expected {
147+
t.Errorf(fmt.Sprintf("unexpected URL path: got %s, want %s", req.URL.Path, expected), http.StatusBadRequest)
148+
}
149+
w.WriteHeader(http.StatusInternalServerError)
150+
},
151+
},
152+
testutil.MockResponse{Description: "delete"},
153+
testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusGone},
154+
)
155+
},
156+
RefreshState: true,
157+
ExpectError: regexp.MustCompile("Error reading credential.*"),
158+
},
159+
},
160+
})
161+
}

0 commit comments

Comments
 (0)