From bd446efc30f846375d0919a84a33ab8c23cd6504 Mon Sep 17 00:00:00 2001 From: Agustin Bettati Date: Mon, 27 Nov 2023 12:56:51 +0100 Subject: [PATCH] test: including unit testing for search deployments state transition logic and model conversions (#1653) * test: including unit testing for search deployments state transition logic and model conversions adjusting to new file structure * fix file change detection criteria for search deployment * adjust naming of files --- .github/workflows/acceptance-tests.yml | 2 +- internal/common/retrystrategy/time_config.go | 9 + .../data_source_search_deployment.go | 4 +- .../model_search_deployment.go | 60 ++++++ .../model_search_deployment_test.go | 118 +++++++++++ .../resource_search_deployment.go | 159 +++----------- .../service_search_deployment.go | 26 +++ .../state_transition_search_deployment.go | 74 +++++++ ...state_transition_search_deployment_test.go | 197 ++++++++++++++++++ 9 files changed, 514 insertions(+), 135 deletions(-) create mode 100644 internal/common/retrystrategy/time_config.go create mode 100644 internal/service/searchdeployment/model_search_deployment.go create mode 100644 internal/service/searchdeployment/model_search_deployment_test.go create mode 100644 internal/service/searchdeployment/service_search_deployment.go create mode 100644 internal/service/searchdeployment/state_transition_search_deployment.go create mode 100644 internal/service/searchdeployment/state_transition_search_deployment_test.go diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml index 7a6b5b3fcd..81b010dc3c 100644 --- a/.github/workflows/acceptance-tests.yml +++ b/.github/workflows/acceptance-tests.yml @@ -51,7 +51,7 @@ jobs: cluster: - 'mongodbatlas/**mongodbatlas_cluster**.go' search_deployment: - - 'mongodbatlas/**search_deployment**.go' + - 'internal/service/searchdeployment/*.go' generic: - 'mongodbatlas/data_source_mongodbatlas_backup_compliance_policy*.go' - 'mongodbatlas/resource_mongodbatlas_backup_compliance_policy*.go' diff --git a/internal/common/retrystrategy/time_config.go b/internal/common/retrystrategy/time_config.go new file mode 100644 index 0000000000..55f135d0ca --- /dev/null +++ b/internal/common/retrystrategy/time_config.go @@ -0,0 +1,9 @@ +package retrystrategy + +import "time" + +type TimeConfig struct { + Timeout time.Duration + MinTimeout time.Duration + Delay time.Duration +} diff --git a/internal/service/searchdeployment/data_source_search_deployment.go b/internal/service/searchdeployment/data_source_search_deployment.go index 12690e56b8..727ef1d767 100644 --- a/internal/service/searchdeployment/data_source_search_deployment.go +++ b/internal/service/searchdeployment/data_source_search_deployment.go @@ -80,7 +80,7 @@ func (d *searchDeploymentDS) Read(ctx context.Context, req datasource.ReadReques return } - newSearchDeploymentModel, diagnostics := newTFSearchDeployment(ctx, clusterName, deploymentResp, nil) + newSearchDeploymentModel, diagnostics := NewTFSearchDeployment(ctx, clusterName, deploymentResp, nil) resp.Diagnostics.Append(diagnostics...) if resp.Diagnostics.HasError() { return @@ -89,7 +89,7 @@ func (d *searchDeploymentDS) Read(ctx context.Context, req datasource.ReadReques resp.Diagnostics.Append(resp.State.Set(ctx, dsModel)...) } -func convertToDSModel(inputModel *tfSearchDeploymentRSModel) tfSearchDeploymentDSModel { +func convertToDSModel(inputModel *TFSearchDeploymentRSModel) tfSearchDeploymentDSModel { return tfSearchDeploymentDSModel{ ID: inputModel.ID, ClusterName: inputModel.ClusterName, diff --git a/internal/service/searchdeployment/model_search_deployment.go b/internal/service/searchdeployment/model_search_deployment.go new file mode 100644 index 0000000000..22dd8f677f --- /dev/null +++ b/internal/service/searchdeployment/model_search_deployment.go @@ -0,0 +1,60 @@ +package searchdeployment + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "go.mongodb.org/atlas-sdk/v20231115001/admin" +) + +func NewSearchDeploymentReq(ctx context.Context, searchDeploymentPlan *TFSearchDeploymentRSModel) admin.ApiSearchDeploymentRequest { + var specs []TFSearchNodeSpecModel + searchDeploymentPlan.Specs.ElementsAs(ctx, &specs, true) + + resultSpecs := make([]admin.ApiSearchDeploymentSpec, len(specs)) + for i, spec := range specs { + resultSpecs[i] = admin.ApiSearchDeploymentSpec{ + InstanceSize: spec.InstanceSize.ValueString(), + NodeCount: int(spec.NodeCount.ValueInt64()), + } + } + + return admin.ApiSearchDeploymentRequest{ + Specs: resultSpecs, + } +} + +func NewTFSearchDeployment(ctx context.Context, clusterName string, deployResp *admin.ApiSearchDeploymentResponse, timeout *timeouts.Value) (*TFSearchDeploymentRSModel, diag.Diagnostics) { + result := TFSearchDeploymentRSModel{ + ID: types.StringPointerValue(deployResp.Id), + ClusterName: types.StringValue(clusterName), + ProjectID: types.StringPointerValue(deployResp.GroupId), + StateName: types.StringPointerValue(deployResp.StateName), + } + + if timeout != nil { + result.Timeouts = *timeout + } + + specsList, diagnostics := types.ListValueFrom(ctx, SpecObjectType, newTFSpecsModel(deployResp.Specs)) + if diagnostics.HasError() { + return nil, diagnostics + } + + result.Specs = specsList + return &result, nil +} + +func newTFSpecsModel(specs []admin.ApiSearchDeploymentSpec) []TFSearchNodeSpecModel { + result := make([]TFSearchNodeSpecModel, len(specs)) + for i, v := range specs { + result[i] = TFSearchNodeSpecModel{ + InstanceSize: types.StringValue(v.InstanceSize), + NodeCount: types.Int64Value(int64(v.NodeCount)), + } + } + + return result +} diff --git a/internal/service/searchdeployment/model_search_deployment_test.go b/internal/service/searchdeployment/model_search_deployment_test.go new file mode 100644 index 0000000000..ca77e865c1 --- /dev/null +++ b/internal/service/searchdeployment/model_search_deployment_test.go @@ -0,0 +1,118 @@ +package searchdeployment_test + +import ( + "context" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/searchdeployment" + "go.mongodb.org/atlas-sdk/v20231115001/admin" +) + +type sdkToTFModelTestCase struct { + SDKResp *admin.ApiSearchDeploymentResponse + expectedTFModel *searchdeployment.TFSearchDeploymentRSModel + name string + clusterName string +} + +const ( + dummyDeploymentID = "111111111111111111111111" + dummyProjectID = "222222222222222222222222" + stateName = "IDLE" + clusterName = "Cluster0" + instanceSize = "S20_HIGHCPU_NVME" + nodeCount = 2 +) + +func TestSearchDeploymentSDKToTFModel(t *testing.T) { + testCases := []sdkToTFModelTestCase{ + { + name: "Complete SDK response", + clusterName: clusterName, + SDKResp: &admin.ApiSearchDeploymentResponse{ + Id: admin.PtrString(dummyDeploymentID), + GroupId: admin.PtrString(dummyProjectID), + StateName: admin.PtrString(stateName), + Specs: []admin.ApiSearchDeploymentSpec{ + { + InstanceSize: instanceSize, + NodeCount: nodeCount, + }, + }, + }, + expectedTFModel: &searchdeployment.TFSearchDeploymentRSModel{ + ID: types.StringValue(dummyDeploymentID), + ClusterName: types.StringValue(clusterName), + ProjectID: types.StringValue(dummyProjectID), + StateName: types.StringValue(stateName), + Specs: tfSpecsList(t, instanceSize, nodeCount), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resultModel, diags := searchdeployment.NewTFSearchDeployment(context.Background(), tc.clusterName, tc.SDKResp, nil) + if diags.HasError() { + t.Errorf("unexpected errors found: %s", diags.Errors()[0].Summary()) + } + if !reflect.DeepEqual(resultModel, tc.expectedTFModel) { + t.Errorf("created terraform model did not match expected output") + } + }) + } +} + +type tfToSDKModelTestCase struct { + name string + tfModel *searchdeployment.TFSearchDeploymentRSModel + expectedSDKReq admin.ApiSearchDeploymentRequest +} + +func TestSearchDeploymentTFModelToSDK(t *testing.T) { + testCases := []tfToSDKModelTestCase{ + { + name: "Complete TF state", + tfModel: &searchdeployment.TFSearchDeploymentRSModel{ + ID: types.StringValue(dummyDeploymentID), + ClusterName: types.StringValue(clusterName), + ProjectID: types.StringValue(dummyProjectID), + StateName: types.StringValue(stateName), + Specs: tfSpecsList(t, instanceSize, nodeCount), + }, + expectedSDKReq: admin.ApiSearchDeploymentRequest{ + Specs: []admin.ApiSearchDeploymentSpec{ + { + InstanceSize: instanceSize, + NodeCount: nodeCount, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + apiReqResult := searchdeployment.NewSearchDeploymentReq(context.Background(), tc.tfModel) + if !reflect.DeepEqual(apiReqResult, tc.expectedSDKReq) { + t.Errorf("created sdk model did not match expected output") + } + }) + } +} + +func tfSpecsList(t *testing.T, instanceSize string, nodeCount int64) basetypes.ListValue { + tfSpecsList, diags := types.ListValueFrom(context.Background(), searchdeployment.SpecObjectType, []searchdeployment.TFSearchNodeSpecModel{ + { + InstanceSize: types.StringValue(instanceSize), + NodeCount: types.Int64Value(nodeCount), + }, + }) + if diags.HasError() { + t.Errorf("failed to create terraform spec lists model: %s", diags.Errors()[0].Summary()) + } + return tfSpecsList +} diff --git a/internal/service/searchdeployment/resource_search_deployment.go b/internal/service/searchdeployment/resource_search_deployment.go index a3da98de3d..85c6348b28 100644 --- a/internal/service/searchdeployment/resource_search_deployment.go +++ b/internal/service/searchdeployment/resource_search_deployment.go @@ -3,15 +3,12 @@ package searchdeployment import ( "context" "errors" - "fmt" "regexp" - "strings" "time" "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -19,21 +16,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" - "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/retrystrategy" "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" - "go.mongodb.org/atlas-sdk/v20231115001/admin" ) var _ resource.ResourceWithConfigure = &searchDeploymentRS{} var _ resource.ResourceWithImportState = &searchDeploymentRS{} -const ( - searchDeploymentDoesNotExistsError = "ATLAS_FTS_DEPLOYMENT_DOES_NOT_EXIST" - searchDeploymentName = "search_deployment" -) +const searchDeploymentName = "search_deployment" func NewSearchDeploymentRS() resource.Resource { return &searchDeploymentRS{ @@ -47,7 +37,7 @@ type searchDeploymentRS struct { config.RSCommon } -type tfSearchDeploymentRSModel struct { +type TFSearchDeploymentRSModel struct { ID types.String `tfsdk:"id"` ClusterName types.String `tfsdk:"cluster_name"` ProjectID types.String `tfsdk:"project_id"` @@ -56,7 +46,7 @@ type tfSearchDeploymentRSModel struct { Timeouts timeouts.Value `tfsdk:"timeouts"` } -type tfSearchNodeSpecModel struct { +type TFSearchNodeSpecModel struct { InstanceSize types.String `tfsdk:"instance_size"` NodeCount types.Int64 `tfsdk:"node_count"` } @@ -114,9 +104,19 @@ func (r *searchDeploymentRS) Schema(ctx context.Context, req resource.SchemaRequ } const defaultSearchNodeTimeout time.Duration = 3 * time.Hour +const minTimeoutCreateUpdate time.Duration = 1 * time.Minute +const minTimeoutDelete time.Duration = 30 * time.Second + +func retryTimeConfig(configuredTimeout, minTimeout time.Duration) retrystrategy.TimeConfig { + return retrystrategy.TimeConfig{ + Timeout: configuredTimeout, + MinTimeout: minTimeout, + Delay: 1 * time.Minute, + } +} func (r *searchDeploymentRS) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var searchDeploymentPlan tfSearchDeploymentRSModel + var searchDeploymentPlan TFSearchDeploymentRSModel resp.Diagnostics.Append(req.Plan.Get(ctx, &searchDeploymentPlan)...) if resp.Diagnostics.HasError() { return @@ -125,7 +125,7 @@ func (r *searchDeploymentRS) Create(ctx context.Context, req resource.CreateRequ connV2 := r.Client.AtlasV2 projectID := searchDeploymentPlan.ProjectID.ValueString() clusterName := searchDeploymentPlan.ClusterName.ValueString() - searchDeploymentReq := newSearchDeploymentReq(ctx, &searchDeploymentPlan) + searchDeploymentReq := NewSearchDeploymentReq(ctx, &searchDeploymentPlan) if _, _, err := connV2.AtlasSearchApi.CreateAtlasSearchDeployment(ctx, projectID, clusterName, &searchDeploymentReq).Execute(); err != nil { resp.Diagnostics.AddError("error during search deployment creation", err.Error()) return @@ -136,12 +136,13 @@ func (r *searchDeploymentRS) Create(ctx context.Context, req resource.CreateRequ if resp.Diagnostics.HasError() { return } - deploymentResp, err := waitSearchNodeStateTransition(ctx, projectID, clusterName, connV2, createTimeout) + deploymentResp, err := WaitSearchNodeStateTransition(ctx, projectID, clusterName, ServiceFromClient(connV2), + retryTimeConfig(createTimeout, minTimeoutCreateUpdate)) if err != nil { resp.Diagnostics.AddError("error during search deployment creation", err.Error()) return } - newSearchNodeModel, diagnostics := newTFSearchDeployment(ctx, clusterName, deploymentResp, &searchDeploymentPlan.Timeouts) + newSearchNodeModel, diagnostics := NewTFSearchDeployment(ctx, clusterName, deploymentResp, &searchDeploymentPlan.Timeouts) resp.Diagnostics.Append(diagnostics...) if resp.Diagnostics.HasError() { return @@ -150,7 +151,7 @@ func (r *searchDeploymentRS) Create(ctx context.Context, req resource.CreateRequ } func (r *searchDeploymentRS) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var searchDeploymentPlan tfSearchDeploymentRSModel + var searchDeploymentPlan TFSearchDeploymentRSModel resp.Diagnostics.Append(req.State.Get(ctx, &searchDeploymentPlan)...) if resp.Diagnostics.HasError() { return @@ -165,7 +166,7 @@ func (r *searchDeploymentRS) Read(ctx context.Context, req resource.ReadRequest, return } - newSearchNodeModel, diagnostics := newTFSearchDeployment(ctx, clusterName, deploymentResp, &searchDeploymentPlan.Timeouts) + newSearchNodeModel, diagnostics := NewTFSearchDeployment(ctx, clusterName, deploymentResp, &searchDeploymentPlan.Timeouts) resp.Diagnostics.Append(diagnostics...) if resp.Diagnostics.HasError() { return @@ -174,7 +175,7 @@ func (r *searchDeploymentRS) Read(ctx context.Context, req resource.ReadRequest, } func (r *searchDeploymentRS) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var searchDeploymentPlan tfSearchDeploymentRSModel + var searchDeploymentPlan TFSearchDeploymentRSModel resp.Diagnostics.Append(req.Plan.Get(ctx, &searchDeploymentPlan)...) if resp.Diagnostics.HasError() { return @@ -183,7 +184,7 @@ func (r *searchDeploymentRS) Update(ctx context.Context, req resource.UpdateRequ connV2 := r.Client.AtlasV2 projectID := searchDeploymentPlan.ProjectID.ValueString() clusterName := searchDeploymentPlan.ClusterName.ValueString() - searchDeploymentReq := newSearchDeploymentReq(ctx, &searchDeploymentPlan) + searchDeploymentReq := NewSearchDeploymentReq(ctx, &searchDeploymentPlan) if _, _, err := connV2.AtlasSearchApi.UpdateAtlasSearchDeployment(ctx, projectID, clusterName, &searchDeploymentReq).Execute(); err != nil { resp.Diagnostics.AddError("error during search deployment update", err.Error()) return @@ -194,12 +195,13 @@ func (r *searchDeploymentRS) Update(ctx context.Context, req resource.UpdateRequ if resp.Diagnostics.HasError() { return } - deploymentResp, err := waitSearchNodeStateTransition(ctx, projectID, clusterName, connV2, updateTimeout) + deploymentResp, err := WaitSearchNodeStateTransition(ctx, projectID, clusterName, ServiceFromClient(connV2), + retryTimeConfig(updateTimeout, minTimeoutCreateUpdate)) if err != nil { resp.Diagnostics.AddError("error during search deployment update", err.Error()) return } - newSearchNodeModel, diagnostics := newTFSearchDeployment(ctx, clusterName, deploymentResp, &searchDeploymentPlan.Timeouts) + newSearchNodeModel, diagnostics := NewTFSearchDeployment(ctx, clusterName, deploymentResp, &searchDeploymentPlan.Timeouts) resp.Diagnostics.Append(diagnostics...) if resp.Diagnostics.HasError() { return @@ -208,7 +210,7 @@ func (r *searchDeploymentRS) Update(ctx context.Context, req resource.UpdateRequ } func (r *searchDeploymentRS) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var searchDeploymentState *tfSearchDeploymentRSModel + var searchDeploymentState *TFSearchDeploymentRSModel resp.Diagnostics.Append(req.State.Get(ctx, &searchDeploymentState)...) if resp.Diagnostics.HasError() { return @@ -227,7 +229,7 @@ func (r *searchDeploymentRS) Delete(ctx context.Context, req resource.DeleteRequ if resp.Diagnostics.HasError() { return } - if err := waitSearchNodeDelete(ctx, projectID, clusterName, connV2, deleteTimeout); err != nil { + if err := WaitSearchNodeDelete(ctx, projectID, clusterName, ServiceFromClient(connV2), retryTimeConfig(deleteTimeout, minTimeoutDelete)); err != nil { resp.Diagnostics.AddError("error during search deployment delete", err.Error()) return } @@ -260,110 +262,3 @@ func splitSearchNodeImportID(id string) (projectID, clusterName string, err erro clusterName = parts[2] return } - -func waitSearchNodeStateTransition(ctx context.Context, projectID, clusterName string, client *admin.APIClient, timeout time.Duration) (*admin.ApiSearchDeploymentResponse, error) { - stateConf := &retry.StateChangeConf{ - Pending: []string{retrystrategy.RetryStrategyUpdatingState, retrystrategy.RetryStrategyPausedState}, - Target: []string{retrystrategy.RetryStrategyIdleState}, - Refresh: searchDeploymentRefreshFunc(ctx, projectID, clusterName, client), - Timeout: timeout, - MinTimeout: 1 * time.Minute, - Delay: 1 * time.Minute, - } - - result, err := stateConf.WaitForStateContext(ctx) - if err != nil { - return nil, err - } - if deploymentResp, ok := result.(*admin.ApiSearchDeploymentResponse); ok && deploymentResp != nil { - return deploymentResp, nil - } - return nil, errors.New("did not obtain valid result when waiting for search deployment state transition") -} - -func waitSearchNodeDelete(ctx context.Context, projectID, clusterName string, client *admin.APIClient, timeout time.Duration) error { - stateConf := &retry.StateChangeConf{ - Pending: []string{retrystrategy.RetryStrategyIdleState, retrystrategy.RetryStrategyUpdatingState, retrystrategy.RetryStrategyPausedState}, - Target: []string{retrystrategy.RetryStrategyDeletedState}, - Refresh: searchDeploymentRefreshFunc(ctx, projectID, clusterName, client), - Timeout: timeout, - MinTimeout: 30 * time.Second, - Delay: 1 * time.Minute, - } - _, err := stateConf.WaitForStateContext(ctx) - return err -} - -func searchDeploymentRefreshFunc(ctx context.Context, projectID, clusterName string, client *admin.APIClient) retry.StateRefreshFunc { - return func() (any, string, error) { - deploymentResp, resp, err := client.AtlasSearchApi.GetAtlasSearchDeployment(ctx, projectID, clusterName).Execute() - if err != nil && deploymentResp == nil && resp == nil { - return nil, "", err - } - if err != nil { - if resp.StatusCode == 400 && strings.Contains(err.Error(), searchDeploymentDoesNotExistsError) { - return "", retrystrategy.RetryStrategyDeletedState, nil - } - if resp.StatusCode == 503 { - return "", retrystrategy.RetryStrategyPendingState, nil - } - return nil, "", err - } - - if conversion.IsStringPresent(deploymentResp.StateName) { - tflog.Debug(ctx, fmt.Sprintf("search deployment status: %s", *deploymentResp.StateName)) - return deploymentResp, *deploymentResp.StateName, nil - } - return deploymentResp, "", nil - } -} - -func newSearchDeploymentReq(ctx context.Context, searchDeploymentPlan *tfSearchDeploymentRSModel) admin.ApiSearchDeploymentRequest { - var specs []tfSearchNodeSpecModel - searchDeploymentPlan.Specs.ElementsAs(ctx, &specs, true) - - resultSpecs := make([]admin.ApiSearchDeploymentSpec, len(specs)) - for i, spec := range specs { - resultSpecs[i] = admin.ApiSearchDeploymentSpec{ - InstanceSize: spec.InstanceSize.ValueString(), - NodeCount: int(spec.NodeCount.ValueInt64()), - } - } - - return admin.ApiSearchDeploymentRequest{ - Specs: resultSpecs, - } -} - -func newTFSearchDeployment(ctx context.Context, clusterName string, deployResp *admin.ApiSearchDeploymentResponse, timeout *timeouts.Value) (*tfSearchDeploymentRSModel, diag.Diagnostics) { - result := tfSearchDeploymentRSModel{ - ID: types.StringPointerValue(deployResp.Id), - ClusterName: types.StringValue(clusterName), - ProjectID: types.StringPointerValue(deployResp.GroupId), - StateName: types.StringPointerValue(deployResp.StateName), - } - - if timeout != nil { - result.Timeouts = *timeout - } - - specsList, diagnostics := types.ListValueFrom(ctx, SpecObjectType, newTFSpecsModel(deployResp.Specs)) - if diagnostics.HasError() { - return nil, diagnostics - } - - result.Specs = specsList - return &result, nil -} - -func newTFSpecsModel(specs []admin.ApiSearchDeploymentSpec) []tfSearchNodeSpecModel { - result := make([]tfSearchNodeSpecModel, len(specs)) - for i, v := range specs { - result[i] = tfSearchNodeSpecModel{ - InstanceSize: types.StringValue(v.InstanceSize), - NodeCount: types.Int64Value(int64(v.NodeCount)), - } - } - - return result -} diff --git a/internal/service/searchdeployment/service_search_deployment.go b/internal/service/searchdeployment/service_search_deployment.go new file mode 100644 index 0000000000..5cec6b676a --- /dev/null +++ b/internal/service/searchdeployment/service_search_deployment.go @@ -0,0 +1,26 @@ +package searchdeployment + +import ( + "context" + "net/http" + + "go.mongodb.org/atlas-sdk/v20231115001/admin" +) + +type DeploymentService interface { + GetAtlasSearchDeployment(ctx context.Context, groupID, clusterName string) (*admin.ApiSearchDeploymentResponse, *http.Response, error) +} + +type DeploymentServiceFromClient struct { + client *admin.APIClient +} + +func (a *DeploymentServiceFromClient) GetAtlasSearchDeployment(ctx context.Context, groupID, clusterName string) (*admin.ApiSearchDeploymentResponse, *http.Response, error) { + return a.client.AtlasSearchApi.GetAtlasSearchDeployment(ctx, groupID, clusterName).Execute() +} + +func ServiceFromClient(client *admin.APIClient) DeploymentService { + return &DeploymentServiceFromClient{ + client: client, + } +} diff --git a/internal/service/searchdeployment/state_transition_search_deployment.go b/internal/service/searchdeployment/state_transition_search_deployment.go new file mode 100644 index 0000000000..686d62c836 --- /dev/null +++ b/internal/service/searchdeployment/state_transition_search_deployment.go @@ -0,0 +1,74 @@ +package searchdeployment + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/retrystrategy" + "go.mongodb.org/atlas-sdk/v20231115001/admin" +) + +const SearchDeploymentDoesNotExistsError = "ATLAS_FTS_DEPLOYMENT_DOES_NOT_EXIST" + +func WaitSearchNodeStateTransition(ctx context.Context, projectID, clusterName string, client DeploymentService, + timeConfig retrystrategy.TimeConfig) (*admin.ApiSearchDeploymentResponse, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{retrystrategy.RetryStrategyUpdatingState, retrystrategy.RetryStrategyPausedState}, + Target: []string{retrystrategy.RetryStrategyIdleState}, + Refresh: searchDeploymentRefreshFunc(ctx, projectID, clusterName, client), + Timeout: timeConfig.Timeout, + MinTimeout: timeConfig.MinTimeout, + Delay: timeConfig.Delay, + } + + result, err := stateConf.WaitForStateContext(ctx) + if err != nil { + return nil, err + } + if deploymentResp, ok := result.(*admin.ApiSearchDeploymentResponse); ok && deploymentResp != nil { + return deploymentResp, nil + } + return nil, errors.New("did not obtain valid result when waiting for search deployment state transition") +} + +func WaitSearchNodeDelete(ctx context.Context, projectID, clusterName string, client DeploymentService, timeConfig retrystrategy.TimeConfig) error { + stateConf := &retry.StateChangeConf{ + Pending: []string{retrystrategy.RetryStrategyIdleState, retrystrategy.RetryStrategyUpdatingState, retrystrategy.RetryStrategyPausedState}, + Target: []string{retrystrategy.RetryStrategyDeletedState}, + Refresh: searchDeploymentRefreshFunc(ctx, projectID, clusterName, client), + Timeout: timeConfig.Timeout, + MinTimeout: timeConfig.MinTimeout, + Delay: timeConfig.Delay, + } + _, err := stateConf.WaitForStateContext(ctx) + return err +} + +func searchDeploymentRefreshFunc(ctx context.Context, projectID, clusterName string, client DeploymentService) retry.StateRefreshFunc { + return func() (any, string, error) { + deploymentResp, resp, err := client.GetAtlasSearchDeployment(ctx, projectID, clusterName) + if err != nil && deploymentResp == nil && resp == nil { + return nil, "", err + } + if err != nil { + if resp.StatusCode == 400 && strings.Contains(err.Error(), SearchDeploymentDoesNotExistsError) { + return "", retrystrategy.RetryStrategyDeletedState, nil + } + if resp.StatusCode == 503 { + return "", retrystrategy.RetryStrategyUpdatingState, nil + } + return nil, "", err + } + + if conversion.IsStringPresent(deploymentResp.StateName) { + tflog.Debug(ctx, fmt.Sprintf("search deployment status: %s", *deploymentResp.StateName)) + return deploymentResp, *deploymentResp.StateName, nil + } + return deploymentResp, "", nil + } +} diff --git a/internal/service/searchdeployment/state_transition_search_deployment_test.go b/internal/service/searchdeployment/state_transition_search_deployment_test.go new file mode 100644 index 0000000000..3a394d5b6e --- /dev/null +++ b/internal/service/searchdeployment/state_transition_search_deployment_test.go @@ -0,0 +1,197 @@ +package searchdeployment_test + +import ( + "context" + "errors" + "log" + "net/http" + "reflect" + "testing" + "time" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/retrystrategy" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/searchdeployment" + "go.mongodb.org/atlas-sdk/v20231115001/admin" +) + +type stateTransitionTestCase struct { + expectedResult *admin.ApiSearchDeploymentResponse + name string + mockResponses []SearchDeploymentResponse + expectedError bool +} + +func TestSearchDeploymentStateTransition(t *testing.T) { + testCases := []stateTransitionTestCase{ + { + name: "Successful transition to IDLE", + mockResponses: []SearchDeploymentResponse{ + { + DeploymentResp: responseWithState("UPDATING"), + }, + { + DeploymentResp: responseWithState("IDLE"), + }, + }, + expectedResult: responseWithState("IDLE"), + expectedError: false, + }, + { + name: "Successful transition to IDLE with 503 error in between", + mockResponses: []SearchDeploymentResponse{ + { + DeploymentResp: responseWithState("UPDATING"), + }, + { + DeploymentResp: nil, + HTTPResponse: &http.Response{StatusCode: 503}, + Err: errors.New("Service Unavailable"), + }, + { + DeploymentResp: responseWithState("IDLE"), + }, + }, + expectedResult: responseWithState("IDLE"), + expectedError: false, + }, + { + name: "Error when transitioning to an unknown state", + mockResponses: []SearchDeploymentResponse{ + { + DeploymentResp: responseWithState("UPDATING"), + }, + { + DeploymentResp: responseWithState(""), + }, + }, + expectedResult: nil, + expectedError: true, + }, + { + name: "Error when API responds with error", + mockResponses: []SearchDeploymentResponse{ + { + DeploymentResp: nil, + HTTPResponse: &http.Response{StatusCode: 500}, + Err: errors.New("Internal server error"), + }, + }, + expectedResult: nil, + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockService := MockSearchDeploymentService{ + MockResponses: tc.mockResponses, + } + + resp, err := searchdeployment.WaitSearchNodeStateTransition(context.Background(), dummyProjectID, "Cluster0", &mockService, testTimeoutConfig) + + if (err != nil) != tc.expectedError { + t.Errorf("Case %s: Received unexpected error: %v", tc.name, err) + } + + if !reflect.DeepEqual(tc.expectedResult, resp) { + t.Errorf("Case %s: Response did not match expected output", tc.name) + } + }) + } +} + +func TestSearchDeploymentStateTransitionForDelete(t *testing.T) { + testCases := []stateTransitionTestCase{ + { + name: "Regular transition to DELETED", + mockResponses: []SearchDeploymentResponse{ + { + DeploymentResp: responseWithState("UPDATING"), + }, + { + DeploymentResp: nil, + HTTPResponse: &http.Response{StatusCode: 400}, + Err: errors.New(searchdeployment.SearchDeploymentDoesNotExistsError), + }, + }, + expectedError: false, + }, + { + name: "Error when API responds with error", + mockResponses: []SearchDeploymentResponse{ + { + DeploymentResp: nil, + HTTPResponse: &http.Response{StatusCode: 500}, + Err: errors.New("Internal server error"), + }, + }, + expectedError: true, + }, + { + name: "Failed delete when responding with unknown state", + mockResponses: []SearchDeploymentResponse{ + { + DeploymentResp: responseWithState("UPDATING"), + }, + { + DeploymentResp: responseWithState(""), + }, + }, + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockService := MockSearchDeploymentService{ + MockResponses: tc.mockResponses, + } + + err := searchdeployment.WaitSearchNodeDelete(context.Background(), dummyProjectID, clusterName, &mockService, testTimeoutConfig) + + if (err != nil) != tc.expectedError { + t.Errorf("Case %s: Received unexpected error: %v", tc.name, err) + } + }) + } +} + +var testTimeoutConfig = retrystrategy.TimeConfig{ + Timeout: 30 * time.Second, + MinTimeout: 100 * time.Millisecond, + Delay: 0, +} + +func responseWithState(state string) *admin.ApiSearchDeploymentResponse { + return &admin.ApiSearchDeploymentResponse{ + GroupId: admin.PtrString(dummyProjectID), + Id: admin.PtrString(dummyDeploymentID), + Specs: []admin.ApiSearchDeploymentSpec{ + { + InstanceSize: instanceSize, + NodeCount: nodeCount, + }, + }, + StateName: admin.PtrString(state), + } +} + +type MockSearchDeploymentService struct { + MockResponses []SearchDeploymentResponse + index int +} + +func (a *MockSearchDeploymentService) GetAtlasSearchDeployment(ctx context.Context, groupID, clusterName string) (*admin.ApiSearchDeploymentResponse, *http.Response, error) { + if a.index >= len(a.MockResponses) { + log.Fatal(errors.New("no more mocked responses available")) + } + resp := a.MockResponses[a.index] + a.index++ + return resp.DeploymentResp, resp.HTTPResponse, resp.Err +} + +type SearchDeploymentResponse struct { + DeploymentResp *admin.ApiSearchDeploymentResponse + HTTPResponse *http.Response + Err error +}