Skip to content

Commit 077ff87

Browse files
authored
chore: Implement full wait functionality for long-running operations (#3305)
* wip * update templates * state attribute with JSON naming * improve not found logic * delete TODO comment * revert timeout * typo * error if no pending states
1 parent 4c45d7f commit 077ff87

File tree

5 files changed

+133
-71
lines changed

5 files changed

+133
-71
lines changed

internal/common/autogen/handle_operations.go

Lines changed: 103 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package autogen
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
57
"fmt"
68
"io"
79
"net/http"
810
"time"
911

1012
"github.com/hashicorp/terraform-plugin-framework/diag"
1113
"github.com/hashicorp/terraform-plugin-framework/resource"
14+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
15+
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/retrystrategy"
1216
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/validate"
1317
"github.com/mongodb/terraform-provider-mongodbatlas/internal/config"
1418
)
@@ -45,27 +49,27 @@ type HandleCreateReq struct {
4549

4650
func HandleCreate(ctx context.Context, req HandleCreateReq) {
4751
d := &req.Resp.Diagnostics
48-
reqBody, err := Marshal(req.Plan, false)
52+
bodyReq, err := Marshal(req.Plan, false)
4953
if err != nil {
5054
addError(d, opCreate, errBuildingAPIRequest, err)
5155
return
5256
}
53-
respBody, err := callAPIWithBody(ctx, req.Client, req.CallParams, reqBody)
57+
bodyResp, err := callAPIWithBody(ctx, req.Client, req.CallParams, bodyReq)
5458
if err != nil {
5559
addError(d, opCreate, errCallingAPI, err)
5660
return
5761
}
5862

5963
// Use the plan as the base model to set the response state
60-
if err := Unmarshal(respBody, req.Plan); err != nil {
64+
if err := Unmarshal(bodyResp, req.Plan); err != nil {
6165
addError(d, opCreate, errUnmarshallingResponse, err)
6266
return
6367
}
6468
if err := ResolveUnknowns(req.Plan); err != nil {
6569
addError(d, opCreate, errResolvingResponse, err)
6670
return
6771
}
68-
if err := handleWait(ctx, req.Wait, req.Client, req.Plan); err != nil {
72+
if err := handleWaitCreateUpdate(ctx, req.Wait, req.Client, req.Plan); err != nil {
6973
addError(d, opCreate, errWaitingForChanges, err)
7074
return
7175
}
@@ -81,18 +85,18 @@ type HandleReadReq struct {
8185

8286
func HandleRead(ctx context.Context, req HandleReadReq) {
8387
d := &req.Resp.Diagnostics
84-
respBody, apiResp, err := callAPIWithoutBody(ctx, req.Client, req.CallParams)
88+
bodyResp, apiResp, err := callAPIWithoutBody(ctx, req.Client, req.CallParams)
89+
if notFound(bodyResp, apiResp) {
90+
req.Resp.State.RemoveResource(ctx)
91+
return
92+
}
8593
if err != nil {
86-
if validate.StatusNotFound(apiResp) {
87-
req.Resp.State.RemoveResource(ctx)
88-
return
89-
}
9094
addError(d, opRead, errCallingAPI, err)
9195
return
9296
}
9397

9498
// Use the current state as the base model to set the response state
95-
if err := Unmarshal(respBody, req.State); err != nil {
99+
if err := Unmarshal(bodyResp, req.State); err != nil {
96100
addError(d, opRead, errUnmarshallingResponse, err)
97101
return
98102
}
@@ -113,27 +117,27 @@ type HandleUpdateReq struct {
113117

114118
func HandleUpdate(ctx context.Context, req HandleUpdateReq) {
115119
d := &req.Resp.Diagnostics
116-
reqBody, err := Marshal(req.Plan, true)
120+
bodyReq, err := Marshal(req.Plan, true)
117121
if err != nil {
118122
addError(d, opUpdate, errBuildingAPIRequest, err)
119123
return
120124
}
121-
respBody, err := callAPIWithBody(ctx, req.Client, req.CallParams, reqBody)
125+
bodyResp, err := callAPIWithBody(ctx, req.Client, req.CallParams, bodyReq)
122126
if err != nil {
123127
addError(d, opUpdate, errCallingAPI, err)
124128
return
125129
}
126130

127131
// Use the plan as the base model to set the response state
128-
if err := Unmarshal(respBody, req.Plan); err != nil {
132+
if err := Unmarshal(bodyResp, req.Plan); err != nil {
129133
addError(d, opUpdate, errUnmarshallingResponse, err)
130134
return
131135
}
132136
if err := ResolveUnknowns(req.Plan); err != nil {
133137
addError(d, opUpdate, errResolvingResponse, err)
134138
return
135139
}
136-
if err := handleWait(ctx, req.Wait, req.Client, req.Plan); err != nil {
140+
if err := handleWaitCreateUpdate(ctx, req.Wait, req.Client, req.Plan); err != nil {
137141
addError(d, opUpdate, errWaitingForChanges, err)
138142
return
139143
}
@@ -154,70 +158,128 @@ func HandleDelete(ctx context.Context, req HandleDeleteReq) {
154158
addError(d, opDelete, errCallingAPI, err)
155159
return
156160
}
157-
// don't consider wait errors as it can happen that the resource is already deleted
158-
_ = handleWait(ctx, req.Wait, req.Client, req.State)
161+
if err := handleWaitDelete(ctx, req.Wait, req.Client); err != nil {
162+
addError(d, opDelete, errWaitingForChanges, err)
163+
}
159164
}
160165

161-
// handleWait waits until a long-running operation is done if needed.
166+
// handleWaitCreateUpdate waits until a long-running operation is done if needed.
162167
// It also updates the model with the latest JSON response from the API.
163-
func handleWait(ctx context.Context, wait *WaitReq, client *config.MongoDBClient, model any) error {
168+
func handleWaitCreateUpdate(ctx context.Context, wait *WaitReq, client *config.MongoDBClient, model any) error {
164169
if wait == nil {
165170
return nil
166171
}
167-
respBody, err := waitForChanges(ctx, wait, client)
168-
if err != nil {
172+
bodyResp, err := waitForChanges(ctx, wait, client)
173+
if err != nil || isEmptyJSON(bodyResp) {
169174
return err
170175
}
171-
if respBody == nil {
176+
if err := Unmarshal(bodyResp, model); err != nil {
177+
return err
178+
}
179+
return ResolveUnknowns(model)
180+
}
181+
182+
// handleWaitDelete waits until a long-running operation to delete a resource if neeed.
183+
func handleWaitDelete(ctx context.Context, wait *WaitReq, client *config.MongoDBClient) error {
184+
if wait == nil {
172185
return nil
173186
}
174-
if err := Unmarshal(respBody, model); err != nil {
187+
if _, err := waitForChanges(ctx, wait, client); err != nil {
175188
return err
176189
}
177-
return ResolveUnknowns(model)
190+
return nil
178191
}
179192

180-
// waitForChanges waits until a long-running operation is done.
181-
// It returns the latest JSON response from the API so it can be used to update the response state.
182-
// TODO: This is a basic implementation, it will be replaced in CLOUDP-314960.
183-
func waitForChanges(ctx context.Context, wait *WaitReq, client *config.MongoDBClient) ([]byte, error) {
184-
time.Sleep(time.Duration(wait.TimeoutSeconds) * time.Second) // TODO: TimeoutSeconds is temporarily used to allow time to destroy the resource until autogen long-running operations are supported in CLOUDP-314960
185-
respBody, _, err := callAPIWithoutBody(ctx, client, wait.CallParams)
186-
return respBody, err
193+
func addError(d *diag.Diagnostics, opName, errSummary string, err error) {
194+
d.AddError(fmt.Sprintf("Error %s in %s", errSummary, opName), err.Error())
187195
}
188196

189197
// callAPIWithBody makes a request to the API with the given request body and returns the response body.
190198
// It is used for POST, PUT, and PATCH requests where a request body is required.
191-
func callAPIWithBody(ctx context.Context, client *config.MongoDBClient, callParams *config.APICallParams, reqBody []byte) ([]byte, error) {
192-
callParams.Body = reqBody
193-
apiResp, err := client.UntypedAPICall(ctx, callParams)
199+
func callAPIWithBody(ctx context.Context, client *config.MongoDBClient, callParams *config.APICallParams, bodyReq []byte) ([]byte, error) {
200+
apiResp, err := client.UntypedAPICall(ctx, callParams, bodyReq)
194201
if err != nil {
195202
return nil, err
196203
}
197-
respBody, err := io.ReadAll(apiResp.Body)
204+
bodyResp, err := io.ReadAll(apiResp.Body)
198205
apiResp.Body.Close()
199206
if err != nil {
200207
return nil, err
201208
}
202-
return respBody, nil
209+
return bodyResp, nil
203210
}
204211

205212
// callAPIWithoutBody makes a request to the API without a request body and returns the response body.
206213
// It is used for GET or DELETE requests where no request body is required.
207214
func callAPIWithoutBody(ctx context.Context, client *config.MongoDBClient, callParams *config.APICallParams) ([]byte, *http.Response, error) {
208-
callParams.Body = nil
209-
apiResp, err := client.UntypedAPICall(ctx, callParams)
215+
apiResp, err := client.UntypedAPICall(ctx, callParams, nil)
210216
if err != nil {
211217
return nil, apiResp, err
212218
}
213-
respBody, err := io.ReadAll(apiResp.Body)
219+
bodyResp, err := io.ReadAll(apiResp.Body)
214220
apiResp.Body.Close()
215221
if err != nil {
216222
return nil, apiResp, err
217223
}
218-
return respBody, apiResp, nil
224+
return bodyResp, apiResp, nil
219225
}
220226

221-
func addError(d *diag.Diagnostics, opName, errSummary string, err error) {
222-
d.AddError(fmt.Sprintf("Error %s in %s", errSummary, opName), err.Error())
227+
// waitForChanges waits until a long-running operation is done.
228+
// It returns the latest JSON response from the API so it can be used to update the response state.
229+
func waitForChanges(ctx context.Context, wait *WaitReq, client *config.MongoDBClient) ([]byte, error) {
230+
if len(wait.TargetStates) == 0 {
231+
return nil, fmt.Errorf("wait must have at least one target state, pending states: %v", wait.PendingStates)
232+
}
233+
stateConf := retry.StateChangeConf{
234+
Target: wait.TargetStates,
235+
Pending: wait.PendingStates,
236+
Timeout: time.Duration(wait.TimeoutSeconds) * time.Second,
237+
MinTimeout: time.Duration(wait.MinTimeoutSeconds) * time.Second,
238+
Delay: time.Duration(wait.DelaySeconds) * time.Second,
239+
Refresh: refreshFunc(ctx, wait, client),
240+
}
241+
bodyResp, err := stateConf.WaitForStateContext(ctx)
242+
if err != nil || bodyResp == nil {
243+
return nil, err
244+
}
245+
return bodyResp.([]byte), err
223246
}
247+
248+
// refreshFunc retries until a target state or error happens.
249+
// It uses a special state value of "DELETED" when the API returns 404 or empty object
250+
func refreshFunc(ctx context.Context, wait *WaitReq, client *config.MongoDBClient) retry.StateRefreshFunc {
251+
return func() (result any, state string, err error) {
252+
bodyResp, httpResp, err := callAPIWithoutBody(ctx, client, wait.CallParams)
253+
if notFound(bodyResp, httpResp) {
254+
return emptyJSON, retrystrategy.RetryStrategyDeletedState, nil
255+
}
256+
if err != nil {
257+
return nil, "", err
258+
}
259+
var objJSON map[string]any
260+
if err := json.Unmarshal(bodyResp, &objJSON); err != nil {
261+
return nil, "", err
262+
}
263+
stateValAny, found := objJSON[wait.StateAttribute]
264+
if !found {
265+
return nil, "", fmt.Errorf("wait state attribute not found: %s", wait.StateAttribute)
266+
}
267+
stateValStr, ok := stateValAny.(string)
268+
if !ok {
269+
return nil, "", fmt.Errorf("wait state attribute value is not a string, attribute name: %s, value: %s", wait.StateAttribute, stateValAny)
270+
}
271+
return bodyResp, stateValStr, nil
272+
}
273+
}
274+
275+
// notFound returns if the resource is not found (API response is 404 or response body is empty JSON).
276+
// That is because some resources like search_deployment can return an ok status code with empty json when resource doesn't exist.
277+
func notFound(bodyResp []byte, apiResp *http.Response) bool {
278+
return validate.StatusNotFound(apiResp) || isEmptyJSON(bodyResp)
279+
}
280+
281+
func isEmptyJSON(raw []byte) bool {
282+
return len(raw) == 0 || bytes.Equal(raw, emptyJSON)
283+
}
284+
285+
var emptyJSON = []byte("{}")

internal/config/client.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,9 @@ type APICallParams struct {
241241
RelativePath string
242242
PathParams map[string]string
243243
Method string
244-
Body []byte
245244
}
246245

247-
func (c *MongoDBClient) UntypedAPICall(ctx context.Context, params *APICallParams) (*http.Response, error) {
246+
func (c *MongoDBClient) UntypedAPICall(ctx context.Context, params *APICallParams, bodyReq []byte) (*http.Response, error) {
248247
localBasePath, _ := c.AtlasV2.GetConfig().ServerURLWithContext(ctx, "")
249248
localVarPath := localBasePath + params.RelativePath
250249

@@ -256,11 +255,11 @@ func (c *MongoDBClient) UntypedAPICall(ctx context.Context, params *APICallParam
256255
headerParams["Content-Type"] = params.VersionHeader
257256
headerParams["Accept"] = params.VersionHeader
258257

259-
var reqBody any
260-
if params.Body != nil { // if nil slice is sent with application/json content type SDK method returns an error
261-
reqBody = params.Body
258+
var bodyPost any
259+
if bodyReq != nil { // if nil slice is sent with application/json content type SDK method returns an error
260+
bodyPost = bodyReq
262261
}
263-
apiReq, err := c.AtlasV2.PrepareRequest(ctx, localVarPath, params.Method, reqBody, headerParams, nil, nil, nil)
262+
apiReq, err := c.AtlasV2.PrepareRequest(ctx, localVarPath, params.Method, bodyPost, headerParams, nil, nil, nil)
264263
if err != nil {
265264
return nil, err
266265
}

internal/service/pushbasedlogexportapi/resource.go

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/service/searchdeploymentapi/resource.go

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)