From a37eee48505a8265af05fc16a07fe323fedd1a18 Mon Sep 17 00:00:00 2001 From: sivaram-mongodb Date: Wed, 31 Dec 2025 11:32:47 +0530 Subject: [PATCH] feat: Search Deployment CloudFormation Resource --- cfn-resources/search-deployment/Makefile | 2 +- .../cmd/resource/mappings.go | 43 +- .../cmd/resource/mappings_test.go | 94 +-- .../search-deployment/cmd/resource/model.go | 13 +- .../cmd/resource/resource.go | 339 ++++++-- .../cmd/resource/resource_test.go | 749 ++++++++++++++++++ .../cmd/resource/state_transition.go | 40 +- .../cmd/resource/state_transition_test.go | 195 +++-- .../search-deployment/docs/README.md | 4 + .../docs/apisearchdeploymentspec.md | 2 +- .../mongodb-atlas-searchdeployment.json | 17 +- cfn-resources/search-deployment/template.yml | 2 +- .../test/cfn-test-create-inputs.sh | 50 +- .../test/cfn-test-delete-inputs.sh | 23 +- .../searchdeployment.sample-cfn-request.json | 2 +- 15 files changed, 1338 insertions(+), 237 deletions(-) create mode 100644 cfn-resources/search-deployment/cmd/resource/resource_test.go diff --git a/cfn-resources/search-deployment/Makefile b/cfn-resources/search-deployment/Makefile index a63d470a2..72f56c189 100644 --- a/cfn-resources/search-deployment/Makefile +++ b/cfn-resources/search-deployment/Makefile @@ -30,4 +30,4 @@ run-contract-testing: @echo "==> Run contract testing" make build sam local start-lambda & - cfn test --function-name TestEntrypoint --verbose + cfn test --verbose --enforce-timeout 3600 diff --git a/cfn-resources/search-deployment/cmd/resource/mappings.go b/cfn-resources/search-deployment/cmd/resource/mappings.go index db6240d38..4e11233b8 100644 --- a/cfn-resources/search-deployment/cmd/resource/mappings.go +++ b/cfn-resources/search-deployment/cmd/resource/mappings.go @@ -14,36 +14,49 @@ package resource -import admin20231115014 "go.mongodb.org/atlas-sdk/v20231115014/admin" +import ( + admin20250312010 "go.mongodb.org/atlas-sdk/v20250312010/admin" -func NewCFNSearchDeployment(prevModel *Model, apiResp *admin20231115014.ApiSearchDeploymentResponse) Model { + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" +) + +func NewCFNSearchDeployment(prevModel *Model, apiResp *admin20250312010.ApiSearchDeploymentResponse) Model { respSpecs := apiResp.GetSpecs() resultSpecs := make([]ApiSearchDeploymentSpec, len(respSpecs)) for i := range respSpecs { + instanceSize := respSpecs[i].InstanceSize + // Follow cluster pattern: directly assign NodeCount from API response + // Reference: mongodbatlas-cloudformation-resources/cfn-resources/cluster/cmd/resource/mappings.go:305,317 + // Note: API returns int, but CFN model expects *int, so convert to pointer + nodeCount := respSpecs[i].NodeCount resultSpecs[i] = ApiSearchDeploymentSpec{ - InstanceSize: &respSpecs[i].InstanceSize, - NodeCount: &respSpecs[i].NodeCount, + InstanceSize: &instanceSize, + NodeCount: util.IntPtr(nodeCount), } } - return Model{ - Profile: prevModel.Profile, - ClusterName: prevModel.ClusterName, - ProjectId: prevModel.ProjectId, - Id: apiResp.Id, - Specs: resultSpecs, - StateName: apiResp.StateName, + + finalModel := Model{ + Profile: prevModel.Profile, + ClusterName: prevModel.ClusterName, + ProjectId: prevModel.ProjectId, + Id: apiResp.Id, + Specs: resultSpecs, + StateName: apiResp.StateName, + EncryptionAtRestProvider: apiResp.EncryptionAtRestProvider, } + + return finalModel } -func NewSearchDeploymentReq(model *Model) admin20231115014.ApiSearchDeploymentRequest { +func NewSearchDeploymentReq(model *Model) admin20250312010.ApiSearchDeploymentRequest { modelSpecs := model.Specs - requestSpecs := make([]admin20231115014.ApiSearchDeploymentSpec, len(modelSpecs)) + requestSpecs := make([]admin20250312010.ApiSearchDeploymentSpec, len(modelSpecs)) for i, spec := range modelSpecs { // Both spec fields are required in CFN model and will be defined - requestSpecs[i] = admin20231115014.ApiSearchDeploymentSpec{ + requestSpecs[i] = admin20250312010.ApiSearchDeploymentSpec{ InstanceSize: *spec.InstanceSize, NodeCount: *spec.NodeCount, } } - return admin20231115014.ApiSearchDeploymentRequest{Specs: requestSpecs} + return admin20250312010.ApiSearchDeploymentRequest{Specs: requestSpecs} } diff --git a/cfn-resources/search-deployment/cmd/resource/mappings_test.go b/cfn-resources/search-deployment/cmd/resource/mappings_test.go index 556194132..9031ddb17 100644 --- a/cfn-resources/search-deployment/cmd/resource/mappings_test.go +++ b/cfn-resources/search-deployment/cmd/resource/mappings_test.go @@ -19,13 +19,13 @@ import ( "github.com/mongodb/mongodbatlas-cloudformation-resources/search-deployment/cmd/resource" "github.com/stretchr/testify/assert" - admin20231115014 "go.mongodb.org/atlas-sdk/v20231115014/admin" + admin20250312010 "go.mongodb.org/atlas-sdk/v20250312010/admin" ) type sdkToCFNModelTestCase struct { prevModel resource.Model expectedModel resource.Model - SDKResp admin20231115014.ApiSearchDeploymentResponse + SDKResp admin20250312010.ApiSearchDeploymentResponse name string } @@ -44,15 +44,15 @@ func TestSDKToCFNModel(t *testing.T) { { name: "Complete SDK response", prevModel: resource.Model{ - Profile: admin20231115014.PtrString(profile), - ClusterName: admin20231115014.PtrString(clusterName), - ProjectId: admin20231115014.PtrString(dummyProjectID), + Profile: admin20250312010.PtrString(profile), + ClusterName: admin20250312010.PtrString(clusterName), + ProjectId: admin20250312010.PtrString(dummyProjectID), }, - SDKResp: admin20231115014.ApiSearchDeploymentResponse{ - Id: admin20231115014.PtrString(dummyDeploymentID), - GroupId: admin20231115014.PtrString(dummyProjectID), - StateName: admin20231115014.PtrString(stateName), - Specs: &[]admin20231115014.ApiSearchDeploymentSpec{ + SDKResp: admin20250312010.ApiSearchDeploymentResponse{ + Id: admin20250312010.PtrString(dummyDeploymentID), + GroupId: admin20250312010.PtrString(dummyProjectID), + StateName: admin20250312010.PtrString(stateName), + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{ { InstanceSize: instanceSize, NodeCount: nodeCount, @@ -60,15 +60,15 @@ func TestSDKToCFNModel(t *testing.T) { }, }, expectedModel: resource.Model{ - Profile: admin20231115014.PtrString(profile), - ClusterName: admin20231115014.PtrString(clusterName), - ProjectId: admin20231115014.PtrString(dummyProjectID), - Id: admin20231115014.PtrString(dummyDeploymentID), - StateName: admin20231115014.PtrString(stateName), + Profile: admin20250312010.PtrString(profile), + ClusterName: admin20250312010.PtrString(clusterName), + ProjectId: admin20250312010.PtrString(dummyProjectID), + Id: admin20250312010.PtrString(dummyDeploymentID), + StateName: admin20250312010.PtrString(stateName), Specs: []resource.ApiSearchDeploymentSpec{ { - InstanceSize: admin20231115014.PtrString(instanceSize), - NodeCount: admin20231115014.PtrInt(nodeCount), + InstanceSize: admin20250312010.PtrString(instanceSize), + NodeCount: admin20250312010.PtrInt(nodeCount), }, }, }, @@ -76,22 +76,22 @@ func TestSDKToCFNModel(t *testing.T) { { name: "Empty specs array", prevModel: resource.Model{ - Profile: admin20231115014.PtrString(profile), - ClusterName: admin20231115014.PtrString(clusterName), - ProjectId: admin20231115014.PtrString(dummyProjectID), + Profile: admin20250312010.PtrString(profile), + ClusterName: admin20250312010.PtrString(clusterName), + ProjectId: admin20250312010.PtrString(dummyProjectID), }, - SDKResp: admin20231115014.ApiSearchDeploymentResponse{ - Id: admin20231115014.PtrString(dummyDeploymentID), - GroupId: admin20231115014.PtrString(dummyProjectID), - StateName: admin20231115014.PtrString(stateName), - Specs: &[]admin20231115014.ApiSearchDeploymentSpec{}, + SDKResp: admin20250312010.ApiSearchDeploymentResponse{ + Id: admin20250312010.PtrString(dummyDeploymentID), + GroupId: admin20250312010.PtrString(dummyProjectID), + StateName: admin20250312010.PtrString(stateName), + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{}, }, expectedModel: resource.Model{ - Profile: admin20231115014.PtrString(profile), - ClusterName: admin20231115014.PtrString(clusterName), - ProjectId: admin20231115014.PtrString(dummyProjectID), - Id: admin20231115014.PtrString(dummyDeploymentID), - StateName: admin20231115014.PtrString(stateName), + Profile: admin20250312010.PtrString(profile), + ClusterName: admin20250312010.PtrString(clusterName), + ProjectId: admin20250312010.PtrString(dummyProjectID), + Id: admin20250312010.PtrString(dummyDeploymentID), + StateName: admin20250312010.PtrString(stateName), Specs: []resource.ApiSearchDeploymentSpec{}, }, }, @@ -109,25 +109,25 @@ func TestCFNModelToSDK(t *testing.T) { testCases := []struct { model resource.Model name string - expectedSDKReq admin20231115014.ApiSearchDeploymentRequest + expectedSDKReq admin20250312010.ApiSearchDeploymentRequest }{ { name: "Complete CFN model", model: resource.Model{ - Profile: admin20231115014.PtrString(profile), - ClusterName: admin20231115014.PtrString(clusterName), - ProjectId: admin20231115014.PtrString(dummyProjectID), - Id: admin20231115014.PtrString(dummyDeploymentID), - StateName: admin20231115014.PtrString(stateName), + Profile: admin20250312010.PtrString(profile), + ClusterName: admin20250312010.PtrString(clusterName), + ProjectId: admin20250312010.PtrString(dummyProjectID), + Id: admin20250312010.PtrString(dummyDeploymentID), + StateName: admin20250312010.PtrString(stateName), Specs: []resource.ApiSearchDeploymentSpec{ { - InstanceSize: admin20231115014.PtrString(instanceSize), - NodeCount: admin20231115014.PtrInt(nodeCount), + InstanceSize: admin20250312010.PtrString(instanceSize), + NodeCount: admin20250312010.PtrInt(nodeCount), }, }, }, - expectedSDKReq: admin20231115014.ApiSearchDeploymentRequest{ - Specs: []admin20231115014.ApiSearchDeploymentSpec{ + expectedSDKReq: admin20250312010.ApiSearchDeploymentRequest{ + Specs: []admin20250312010.ApiSearchDeploymentSpec{ { InstanceSize: instanceSize, NodeCount: nodeCount, @@ -138,15 +138,15 @@ func TestCFNModelToSDK(t *testing.T) { { name: "Empty specs array", model: resource.Model{ - Profile: admin20231115014.PtrString(profile), - ClusterName: admin20231115014.PtrString(clusterName), - ProjectId: admin20231115014.PtrString(dummyProjectID), - Id: admin20231115014.PtrString(dummyDeploymentID), - StateName: admin20231115014.PtrString(stateName), + Profile: admin20250312010.PtrString(profile), + ClusterName: admin20250312010.PtrString(clusterName), + ProjectId: admin20250312010.PtrString(dummyProjectID), + Id: admin20250312010.PtrString(dummyDeploymentID), + StateName: admin20250312010.PtrString(stateName), Specs: []resource.ApiSearchDeploymentSpec{}, }, - expectedSDKReq: admin20231115014.ApiSearchDeploymentRequest{ - Specs: []admin20231115014.ApiSearchDeploymentSpec{}, + expectedSDKReq: admin20250312010.ApiSearchDeploymentRequest{ + Specs: []admin20250312010.ApiSearchDeploymentSpec{}, }, }, } diff --git a/cfn-resources/search-deployment/cmd/resource/model.go b/cfn-resources/search-deployment/cmd/resource/model.go index ad25b0af6..e4e00ada3 100644 --- a/cfn-resources/search-deployment/cmd/resource/model.go +++ b/cfn-resources/search-deployment/cmd/resource/model.go @@ -4,12 +4,13 @@ package resource // Model is autogenerated from the json schema type Model struct { - Profile *string `json:",omitempty"` - ClusterName *string `json:",omitempty"` - ProjectId *string `json:",omitempty"` - Id *string `json:",omitempty"` - Specs []ApiSearchDeploymentSpec `json:",omitempty"` - StateName *string `json:",omitempty"` + Profile *string `json:",omitempty"` + ClusterName *string `json:",omitempty"` + ProjectId *string `json:",omitempty"` + Id *string `json:",omitempty"` + Specs []ApiSearchDeploymentSpec `json:",omitempty"` + StateName *string `json:",omitempty"` + EncryptionAtRestProvider *string `json:",omitempty"` } // ApiSearchDeploymentSpec is autogenerated from the json schema diff --git a/cfn-resources/search-deployment/cmd/resource/resource.go b/cfn-resources/search-deployment/cmd/resource/resource.go index 651e0b128..650df5d02 100644 --- a/cfn-resources/search-deployment/cmd/resource/resource.go +++ b/cfn-resources/search-deployment/cmd/resource/resource.go @@ -20,7 +20,7 @@ import ( "net/http" "strings" - admin20231115014 "go.mongodb.org/atlas-sdk/v20231115014/admin" + admin20250312010 "go.mongodb.org/atlas-sdk/v20250312010/admin" "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" @@ -32,165 +32,336 @@ import ( ) const ( - callBackSeconds = 40 - SearchDeploymentDoesNotExistsError = "ATLAS_FTS_DEPLOYMENT_DOES_NOT_EXIST" - SearchDeploymentAlreadyExistsError = "ATLAS_FTS_DEPLOYMENT_ALREADY_EXISTS" + CallBackSeconds = 40 + SearchDeploymentAlreadyExistsErrorAPI = "ATLAS_SEARCH_DEPLOYMENT_ALREADY_EXISTS" + SearchDeploymentDoesNotExistsErrorAPI = "ATLAS_SEARCH_DEPLOYMENT_DOES_NOT_EXIST" ) -var createRequiredFields = []string{constants.ProjectID, constants.ClusterName, constants.Specs} -var readRequiredFields = []string{constants.ProjectID, constants.ClusterName} -var updateRequiredFields = []string{constants.ProjectID, constants.ClusterName, constants.Specs} -var deleteRequiredFields = []string{constants.ProjectID, constants.ClusterName} +var callbackContext = map[string]any{"callbackSearchDeployment": true} + +func IsCallback(req *handler.Request) bool { + _, found := req.CallbackContext["callbackSearchDeployment"] + return found +} + +var ( + CreateRequiredFields = []string{constants.ProjectID, constants.ClusterName, constants.Specs} + ReadRequiredFields = []string{constants.ProjectID, constants.ClusterName} + UpdateRequiredFields = []string{constants.ProjectID, constants.ClusterName, constants.Specs} + DeleteRequiredFields = []string{constants.ProjectID, constants.ClusterName} +) func setup() { util.SetupLogger("mongodb-atlas-searchdeployment") } -func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { +var InitEnvWithClient = func(req handler.Request, currentModel *Model, requiredFields []string) (*admin20250312010.APIClient, *handler.ProgressEvent) { setup() util.SetDefaultProfileIfNotDefined(¤tModel.Profile) - if modelValidation := validator.ValidateModel(createRequiredFields, currentModel); modelValidation != nil { - return *modelValidation, nil + if modelValidation := validator.ValidateModel(requiredFields, currentModel); modelValidation != nil { + return nil, modelValidation } client, progressErr := util.NewAtlasClient(&req, currentModel.Profile) if progressErr != nil { - return *progressErr, nil + return nil, progressErr } - connV2 := client.Atlas20231115014 + return client.AtlasSDK, nil +} - // handling of subsequent retry calls - if _, ok := req.CallbackContext[constants.ID]; ok { - return HandleStateTransition(*connV2, currentModel, constants.IdleState), nil +func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + connV2, peErr := InitEnvWithClient(req, currentModel, CreateRequiredFields) + if peErr != nil { + return *peErr, nil + } + + if IsCallback(&req) { + return ValidateProgress(*connV2, currentModel, false), nil } projectID := util.SafeString(currentModel.ProjectId) clusterName := util.SafeString(currentModel.ClusterName) apiReq := NewSearchDeploymentReq(currentModel) - apiResp, resp, err := connV2.AtlasSearchApi.CreateAtlasSearchDeployment(context.Background(), projectID, clusterName, &apiReq).Execute() + + createResp, resp, err := connV2.AtlasSearchApi.CreateClusterSearchDeployment(context.Background(), projectID, clusterName, &apiReq).Execute() if err != nil { - return handleError(resp, err) + notFound := resp != nil && resp.StatusCode == http.StatusNotFound + alreadyExists := resp != nil && resp.StatusCode == http.StatusConflict && + strings.Contains(err.Error(), SearchDeploymentAlreadyExistsErrorAPI) + + if alreadyExists || notFound { + existingResp, _, getErr := connV2.AtlasSearchApi.GetClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + if getErr != nil { + return HandleError(resp, err) + } + existingModel := NewCFNSearchDeployment(currentModel, existingResp) + return handler.ProgressEvent{ + OperationStatus: handler.Success, + ResourceModel: &existingModel, + Message: constants.Complete, + }, nil + } + return HandleError(resp, err) } - newModel := NewCFNSearchDeployment(currentModel, apiResp) - return inProgressEvent("Creating Search Deployment", &newModel), nil -} + var apiResp *admin20250312010.ApiSearchDeploymentResponse + if createResp != nil && createResp.Id != nil { + apiResp = createResp + } else { + apiResp, resp, err = connV2.AtlasSearchApi.GetClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + if err != nil { + return HandleError(resp, err) + } + if apiResp == nil || apiResp.Id == nil { + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: "Creating Search Deployment - waiting for deployment ID", + ResourceModel: currentModel, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil + } + } -func Read(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { - setup() - util.SetDefaultProfileIfNotDefined(¤tModel.Profile) + newModel := NewCFNSearchDeployment(currentModel, apiResp) + if newModel.Id == nil { + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: "Creating Search Deployment - waiting for deployment ID", + ResourceModel: currentModel, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil + } - if modelValidation := validator.ValidateModel(readRequiredFields, currentModel); modelValidation != nil { - return *modelValidation, nil + stateName := util.SafeString(newModel.StateName) + if stateName == constants.IdleState { + return handler.ProgressEvent{ + OperationStatus: handler.Success, + ResourceModel: &newModel, + Message: constants.Complete, + }, nil } - client, progressErr := util.NewAtlasClient(&req, currentModel.Profile) - if progressErr != nil { - return *progressErr, nil + return inProgressEvent("Creating Search Deployment", currentModel, apiResp), nil +} + +func Read(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + connV2, peErr := InitEnvWithClient(req, currentModel, ReadRequiredFields) + if peErr != nil { + return *peErr, nil } - connV2 := client.Atlas20231115014 projectID := util.SafeString(currentModel.ProjectId) clusterName := util.SafeString(currentModel.ClusterName) - apiResp, resp, err := connV2.AtlasSearchApi.GetAtlasSearchDeployment(context.Background(), projectID, clusterName).Execute() + apiResp, resp, err := connV2.AtlasSearchApi.GetClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + if err != nil { - return handleError(resp, err) + return HandleError(resp, err) + } + + if apiResp == nil || apiResp.Id == nil { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "Search deployment not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + }, nil } + newModel := NewCFNSearchDeployment(currentModel, apiResp) return handler.ProgressEvent{ OperationStatus: handler.Success, - ResourceModel: NewCFNSearchDeployment(currentModel, apiResp), + ResourceModel: &newModel, }, nil } func Update(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { - setup() - util.SetDefaultProfileIfNotDefined(¤tModel.Profile) - - if modelValidation := validator.ValidateModel(updateRequiredFields, currentModel); modelValidation != nil { - return *modelValidation, nil - } - - client, progressErr := util.NewAtlasClient(&req, currentModel.Profile) - if progressErr != nil { - return *progressErr, nil + connV2, peErr := InitEnvWithClient(req, currentModel, UpdateRequiredFields) + if peErr != nil { + return *peErr, nil } - connV2 := client.Atlas20231115014 - // handling of subsequent retry calls - if _, ok := req.CallbackContext[constants.ID]; ok { - return HandleStateTransition(*connV2, currentModel, constants.IdleState), nil + if IsCallback(&req) { + return ValidateProgress(*connV2, currentModel, false), nil } projectID := util.SafeString(currentModel.ProjectId) clusterName := util.SafeString(currentModel.ClusterName) + + // Check if resource exists before updating (required by contract tests) + checkResp, checkHTTPResp, err := connV2.AtlasSearchApi.GetClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + if err != nil { + // If resource doesn't exist, return NotFound (required by contract tests) + if checkHTTPResp != nil && checkHTTPResp.StatusCode == http.StatusNotFound { + return progressevent.GetFailedEventByResponse("Search deployment not found", checkHTTPResp), nil + } + return HandleError(checkHTTPResp, err) + } + if checkResp == nil || checkResp.Id == nil { + // Resource doesn't exist - return NotFound with proper HTTP response + notFoundResp := &http.Response{StatusCode: http.StatusNotFound} + return progressevent.GetFailedEventByResponse("Search deployment not found", notFoundResp), nil + } + apiReq := NewSearchDeploymentReq(currentModel) - apiResp, res, err := connV2.AtlasSearchApi.UpdateAtlasSearchDeployment(context.Background(), projectID, clusterName, &apiReq).Execute() + _, res, err := connV2.AtlasSearchApi.UpdateClusterSearchDeployment(context.Background(), projectID, clusterName, &apiReq).Execute() if err != nil { - return handleError(res, err) + return HandleError(res, err) } - newModel := NewCFNSearchDeployment(currentModel, apiResp) - return inProgressEvent("Updating Search Deployment", &newModel), nil + apiResp, resp, err := connV2.AtlasSearchApi.GetClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + if err != nil { + modelWithID := GetModelWithID(currentModel, prevModel, checkResp) + if modelWithID != nil { + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: "Updating Search Deployment", + ResourceModel: modelWithID, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil + } + return HandleError(resp, err) + } + + if apiResp == nil || apiResp.Id == nil { + modelWithID := GetModelWithID(currentModel, prevModel, checkResp) + if modelWithID != nil { + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: "Updating Search Deployment", + ResourceModel: modelWithID, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil + } + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: "Updating Search Deployment - waiting for deployment ID", + ResourceModel: currentModel, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil + } + + return inProgressEvent("Updating Search Deployment", currentModel, apiResp), nil } func Delete(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { - setup() - util.SetDefaultProfileIfNotDefined(¤tModel.Profile) - - if modelValidation := validator.ValidateModel(deleteRequiredFields, currentModel); modelValidation != nil { - return *modelValidation, nil + if currentModel == nil || (currentModel.ProjectId == nil && currentModel.ClusterName == nil) { + if prevModel != nil && prevModel.ProjectId != nil && prevModel.ClusterName != nil { + currentModel = prevModel + } else { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "Search deployment not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + }, nil + } } - client, progressErr := util.NewAtlasClient(&req, currentModel.Profile) - if progressErr != nil { - return *progressErr, nil + connV2, peErr := InitEnvWithClient(req, currentModel, DeleteRequiredFields) + if peErr != nil { + return *peErr, nil } - connV2 := client.Atlas20231115014 - // handling of subsequent retry calls - if _, ok := req.CallbackContext[constants.ID]; ok { - return HandleStateTransition(*connV2, currentModel, constants.DeletedState), nil + if IsCallback(&req) { + return ValidateProgress(*connV2, currentModel, true), nil } projectID := util.SafeString(currentModel.ProjectId) clusterName := util.SafeString(currentModel.ClusterName) - if resp, err := connV2.AtlasSearchApi.DeleteAtlasSearchDeployment(context.Background(), projectID, clusterName).Execute(); err != nil { - return handleError(resp, err) + + resp, err := connV2.AtlasSearchApi.DeleteClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + if err != nil { + return HandleError(resp, err) + } + + apiResp, _, readErr := connV2.AtlasSearchApi.GetClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + if readErr == nil && apiResp != nil && apiResp.Id != nil { + updatedModel := NewCFNSearchDeployment(currentModel, apiResp) + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: constants.DeleteInProgress, + ResourceModel: &updatedModel, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil } - return inProgressEvent(constants.DeleteInProgress, currentModel), nil + modelWithID := GetModelWithID(currentModel, prevModel, nil) + if modelWithID != nil { + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: constants.DeleteInProgress, + ResourceModel: modelWithID, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil + } + + return handler.ProgressEvent{ + OperationStatus: handler.InProgress, + Message: constants.DeleteInProgress, + ResourceModel: currentModel, + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + }, nil } func List(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { return handler.ProgressEvent{}, errors.New("not implemented: List") } -// specific handling for search deployment API where 400 status code can include AlreadyExists or DoesNotExist that need specific mapping to CFN error codes -func handleError(res *http.Response, err error) (handler.ProgressEvent, error) { - if apiError, ok := admin20231115014.AsError(err); ok && *apiError.Error == http.StatusBadRequest && strings.Contains(*apiError.ErrorCode, SearchDeploymentAlreadyExistsError) { - return handler.ProgressEvent{ - OperationStatus: handler.Failed, - Message: err.Error(), - HandlerErrorCode: string(types.HandlerErrorCodeAlreadyExists)}, nil +func HandleError(res *http.Response, err error) (handler.ProgressEvent, error) { + pe := progressevent.GetFailedEventByResponse(err.Error(), res) + + // Search Deployment API returns 400 BadRequest for both AlreadyExists and NotFound + // Need to check error code to distinguish them + if res != nil && res.StatusCode == http.StatusBadRequest { + if apiError, ok := admin20250312010.AsError(err); ok { + if strings.Contains(apiError.ErrorCode, SearchDeploymentAlreadyExistsErrorAPI) { + pe.HandlerErrorCode = string(types.HandlerErrorCodeAlreadyExists) + } else if strings.Contains(apiError.ErrorCode, SearchDeploymentDoesNotExistsErrorAPI) { + pe.HandlerErrorCode = string(types.HandlerErrorCodeNotFound) + } + } } - if apiError, ok := admin20231115014.AsError(err); ok && *apiError.Error == http.StatusBadRequest && strings.Contains(*apiError.ErrorCode, SearchDeploymentDoesNotExistsError) { - return handler.ProgressEvent{ - OperationStatus: handler.Failed, - Message: err.Error(), - HandlerErrorCode: string(types.HandlerErrorCodeNotFound)}, nil + + if res != nil && res.StatusCode == http.StatusNotFound { + pe.HandlerErrorCode = string(types.HandlerErrorCodeNotFound) + } + if strings.Contains(err.Error(), "not exist") || strings.Contains(err.Error(), "being deleted") { + pe.HandlerErrorCode = string(types.HandlerErrorCodeNotFound) } - return progressevent.GetFailedEventByResponse(err.Error(), res), nil + return pe, nil } -func inProgressEvent(message string, model *Model) handler.ProgressEvent { +func inProgressEvent(message string, model *Model, apiResp *admin20250312010.ApiSearchDeploymentResponse) handler.ProgressEvent { + if apiResp != nil { + newModel := NewCFNSearchDeployment(model, apiResp) + model = &newModel + } return handler.ProgressEvent{ OperationStatus: handler.InProgress, Message: message, ResourceModel: model, - CallbackDelaySeconds: callBackSeconds, - CallbackContext: map[string]interface{}{ - constants.ID: model.Id, - }} + CallbackDelaySeconds: CallBackSeconds, + CallbackContext: callbackContext, + } +} + +func GetModelWithID(currentModel, prevModel *Model, checkResp *admin20250312010.ApiSearchDeploymentResponse) *Model { + if currentModel != nil && currentModel.Id != nil { + return currentModel + } + if prevModel != nil && prevModel.Id != nil { + return prevModel + } + if checkResp != nil && checkResp.Id != nil { + updatedModel := NewCFNSearchDeployment(currentModel, checkResp) + return &updatedModel + } + return nil } diff --git a/cfn-resources/search-deployment/cmd/resource/resource_test.go b/cfn-resources/search-deployment/cmd/resource/resource_test.go new file mode 100644 index 000000000..618187828 --- /dev/null +++ b/cfn-resources/search-deployment/cmd/resource/resource_test.go @@ -0,0 +1,749 @@ +// Copyright 2024 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/mongodb/mongodbatlas-cloudformation-resources/search-deployment/cmd/resource" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + admin20250312010 "go.mongodb.org/atlas-sdk/v20250312010/admin" + "go.mongodb.org/atlas-sdk/v20250312010/mockadmin" +) + +func createTestSearchDeploymentModel() *resource.Model { + projectID := "507f1f77bcf86cd799439011" + clusterName := "test-cluster" + profile := "default" + instanceSize := "S20_HIGHCPU_NVME" + nodeCount := 2 + + return &resource.Model{ + Profile: &profile, + ProjectId: &projectID, + ClusterName: &clusterName, + Specs: []resource.ApiSearchDeploymentSpec{ + { + InstanceSize: &instanceSize, + NodeCount: &nodeCount, + }, + }, + } +} + +func createTestSearchDeploymentResponse() *admin20250312010.ApiSearchDeploymentResponse { + id := "test-id-123" + stateName := "IDLE" + return &admin20250312010.ApiSearchDeploymentResponse{ + Id: &id, + StateName: &stateName, + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{ + { + InstanceSize: "S20_HIGHCPU_NVME", + NodeCount: 2, + }, + }, + } +} + +func TestIsCallback(t *testing.T) { + testCases := map[string]struct { + req handler.Request + expected bool + }{ + "withCallback": { + req: handler.Request{CallbackContext: map[string]interface{}{"callbackSearchDeployment": true}}, + expected: true, + }, + "withoutCallback": { + req: handler.Request{CallbackContext: map[string]interface{}{}}, + expected: false, + }, + "nilCallbackContext": { + req: handler.Request{}, + expected: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result := resource.IsCallback(&tc.req) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConstants(t *testing.T) { + assert.Equal(t, 40, resource.CallBackSeconds) + assert.Equal(t, "ATLAS_SEARCH_DEPLOYMENT_DOES_NOT_EXIST", resource.SearchDeploymentDoesNotExistsErrorAPI) + assert.Equal(t, "ATLAS_SEARCH_DEPLOYMENT_ALREADY_EXISTS", resource.SearchDeploymentAlreadyExistsErrorAPI) +} + +func TestRequiredFields(t *testing.T) { + assert.Equal(t, []string{constants.ProjectID, constants.ClusterName, constants.Specs}, resource.CreateRequiredFields) + assert.Equal(t, []string{constants.ProjectID, constants.ClusterName}, resource.ReadRequiredFields) + assert.Equal(t, []string{constants.ProjectID, constants.ClusterName, constants.Specs}, resource.UpdateRequiredFields) + assert.Equal(t, []string{constants.ProjectID, constants.ClusterName}, resource.DeleteRequiredFields) +} + +func TestList(t *testing.T) { + req := handler.Request{} + event, err := resource.List(req, nil, nil) + + require.Error(t, err) + assert.Equal(t, "not implemented: List", err.Error()) + assert.Equal(t, handler.ProgressEvent{}, event) +} + +func TestGetModelWithID(t *testing.T) { + id := "test-id-123" + projectID := "507f1f77bcf86cd799439011" + clusterName := "test-cluster" + + testCases := map[string]struct { + currentModel *resource.Model + prevModel *resource.Model + checkResp *admin20250312010.ApiSearchDeploymentResponse + expectedID *string + }{ + "currentModelHasID": { + currentModel: &resource.Model{Id: &id, ProjectId: &projectID, ClusterName: &clusterName}, + prevModel: nil, + checkResp: nil, + expectedID: &id, + }, + "prevModelHasID": { + currentModel: &resource.Model{ProjectId: &projectID, ClusterName: &clusterName}, + prevModel: &resource.Model{Id: &id, ProjectId: &projectID, ClusterName: &clusterName}, + checkResp: nil, + expectedID: &id, + }, + "checkRespHasID": { + currentModel: &resource.Model{ProjectId: &projectID, ClusterName: &clusterName}, + prevModel: nil, + checkResp: &admin20250312010.ApiSearchDeploymentResponse{Id: &id}, + expectedID: &id, + }, + "noIDAvailable": { + currentModel: &resource.Model{ProjectId: &projectID, ClusterName: &clusterName}, + prevModel: nil, + checkResp: nil, + expectedID: nil, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result := resource.GetModelWithID(tc.currentModel, tc.prevModel, tc.checkResp) + if tc.expectedID == nil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, *tc.expectedID, *result.Id) + } + }) + } +} + +func TestHandleError(t *testing.T) { + createSDKError := func(errorCode string, statusCode int) *admin20250312010.GenericOpenAPIError { + apiErr := admin20250312010.ApiError{ + Error: statusCode, + ErrorCode: errorCode, + } + sdkErr := &admin20250312010.GenericOpenAPIError{} + sdkErr.SetModel(apiErr) + return sdkErr + } + + testCases := map[string]struct { + response *http.Response + err error + expectedStatus handler.Status + expectedErrorCode string + }{ + "AlreadyExistsError": { + response: &http.Response{StatusCode: http.StatusBadRequest}, + err: createSDKError(resource.SearchDeploymentAlreadyExistsErrorAPI, http.StatusBadRequest), + expectedStatus: handler.Failed, + expectedErrorCode: "AlreadyExists", + }, + "DoesNotExistError": { + response: &http.Response{StatusCode: http.StatusBadRequest}, + err: createSDKError(resource.SearchDeploymentDoesNotExistsErrorAPI, http.StatusBadRequest), + expectedStatus: handler.Failed, + expectedErrorCode: "NotFound", + }, + "NotFoundError": { + response: &http.Response{StatusCode: http.StatusNotFound}, + err: fmt.Errorf("resource not found"), + expectedStatus: handler.Failed, + expectedErrorCode: "NotFound", + }, + "ErrorContainsNotExist": { + response: &http.Response{StatusCode: http.StatusInternalServerError}, + err: fmt.Errorf("resource does not exist"), + expectedStatus: handler.Failed, + expectedErrorCode: "NotFound", + }, + "ErrorContainsBeingDeleted": { + response: &http.Response{StatusCode: http.StatusInternalServerError}, + err: fmt.Errorf("resource is being deleted"), + expectedStatus: handler.Failed, + expectedErrorCode: "NotFound", + }, + "GenericError": { + response: &http.Response{StatusCode: http.StatusInternalServerError}, + err: fmt.Errorf("internal server error"), + expectedStatus: handler.Failed, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + event, err := resource.HandleError(tc.response, tc.err) + + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, event.OperationStatus) + if tc.expectedErrorCode != "" { + assert.Equal(t, tc.expectedErrorCode, event.HandlerErrorCode) + } + }) + } +} + +func TestCreateValidationErrors(t *testing.T) { + testCases := map[string]struct { + currentModel *resource.Model + expectedMsg string + }{ + "missingProjectId": {&resource.Model{ClusterName: util.StringPtr("test-cluster"), Specs: []resource.ApiSearchDeploymentSpec{{InstanceSize: util.StringPtr("S20_HIGHCPU_NVME"), NodeCount: util.IntPtr(2)}}}, "required"}, + "missingClusterName": {&resource.Model{ProjectId: util.StringPtr("507f1f77bcf86cd799439011"), Specs: []resource.ApiSearchDeploymentSpec{{InstanceSize: util.StringPtr("S20_HIGHCPU_NVME"), NodeCount: util.IntPtr(2)}}}, "required"}, + "missingSpecs": {&resource.Model{ProjectId: util.StringPtr("507f1f77bcf86cd799439011"), ClusterName: util.StringPtr("test-cluster")}, "required"}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + event, err := resource.Create(handler.Request{}, nil, tc.currentModel) + require.NoError(t, err) + assert.Equal(t, handler.Failed, event.OperationStatus) + assert.Contains(t, event.Message, tc.expectedMsg) + }) + } +} + +func TestReadValidationErrors(t *testing.T) { + testCases := map[string]struct { + currentModel *resource.Model + expectedMsg string + }{ + "missingProjectId": {&resource.Model{ClusterName: util.StringPtr("test-cluster")}, "required"}, + "missingClusterName": {&resource.Model{ProjectId: util.StringPtr("507f1f77bcf86cd799439011")}, "required"}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + event, err := resource.Read(handler.Request{}, nil, tc.currentModel) + require.NoError(t, err) + assert.Equal(t, handler.Failed, event.OperationStatus) + assert.Contains(t, event.Message, tc.expectedMsg) + }) + } +} + +func TestUpdateValidationErrors(t *testing.T) { + testCases := map[string]struct { + currentModel *resource.Model + expectedMsg string + }{ + "missingProjectId": {&resource.Model{ClusterName: util.StringPtr("test-cluster"), Specs: []resource.ApiSearchDeploymentSpec{{InstanceSize: util.StringPtr("S20_HIGHCPU_NVME"), NodeCount: util.IntPtr(2)}}}, "required"}, + "missingClusterName": {&resource.Model{ProjectId: util.StringPtr("507f1f77bcf86cd799439011"), Specs: []resource.ApiSearchDeploymentSpec{{InstanceSize: util.StringPtr("S20_HIGHCPU_NVME"), NodeCount: util.IntPtr(2)}}}, "required"}, + "missingSpecs": {&resource.Model{ProjectId: util.StringPtr("507f1f77bcf86cd799439011"), ClusterName: util.StringPtr("test-cluster")}, "required"}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + event, err := resource.Update(handler.Request{}, nil, tc.currentModel) + require.NoError(t, err) + assert.Equal(t, handler.Failed, event.OperationStatus) + assert.Contains(t, event.Message, tc.expectedMsg) + }) + } +} + +func TestDeleteValidationErrors(t *testing.T) { + testCases := map[string]struct { + currentModel *resource.Model + expectedMsg string + }{ + "missingProjectId": {&resource.Model{ClusterName: util.StringPtr("test-cluster")}, "required"}, + "missingClusterName": {&resource.Model{ProjectId: util.StringPtr("507f1f77bcf86cd799439011")}, "required"}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + event, err := resource.Delete(handler.Request{}, nil, tc.currentModel) + require.NoError(t, err) + assert.Equal(t, handler.Failed, event.OperationStatus) + assert.Contains(t, event.Message, tc.expectedMsg) + }) + } +} + +func TestCreateWithMocks(t *testing.T) { + originalInitEnv := resource.InitEnvWithClient + defer func() { resource.InitEnvWithClient = originalInitEnv }() + + testCases := map[string]struct { + mockSetup func(*mockadmin.AtlasSearchApi) + validateResult func(t *testing.T, event handler.ProgressEvent) + expectedStatus handler.Status + req handler.Request + }{ + "successfulCreate": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + idleResp := createTestSearchDeploymentResponse() + stateName := "IDLE" + idleResp.StateName = &stateName + m.EXPECT().CreateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.CreateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().CreateClusterSearchDeploymentExecute(mock.Anything). + Return(idleResp, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, constants.Complete, event.Message) + assert.NotNil(t, event.ResourceModel) + }, + }, + "createWithCallback": { + req: handler.Request{CallbackContext: map[string]interface{}{"callbackSearchDeployment": true}}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + }, + "createWithError": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().CreateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.CreateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().CreateClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 500}, fmt.Errorf("API error")) + }, + expectedStatus: handler.Failed, + }, + "createAlreadyExists": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + createErr := fmt.Errorf("error creating: %s", resource.SearchDeploymentAlreadyExistsErrorAPI) + + m.EXPECT().CreateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.CreateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().CreateClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: http.StatusConflict}, createErr) + + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, constants.Complete, event.Message) + }, + }, + "createNotFoundThenSuccess": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().CreateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.CreateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().CreateClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: http.StatusNotFound}, fmt.Errorf("not found")) + + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + }, + "createNoIDInResponse": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + respWithNoID := &admin20250312010.ApiSearchDeploymentResponse{ + StateName: admin20250312010.PtrString("UPDATING"), + } + m.EXPECT().CreateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.CreateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().CreateClusterSearchDeploymentExecute(mock.Anything). + Return(respWithNoID, &http.Response{StatusCode: 200}, nil) + + respWithNoID2 := &admin20250312010.ApiSearchDeploymentResponse{ + StateName: admin20250312010.PtrString("UPDATING"), + } + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(respWithNoID2, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.InProgress, + }, + "createInProgressState": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + updatingResp := createTestSearchDeploymentResponse() + stateName := "UPDATING" + updatingResp.StateName = &stateName + + m.EXPECT().CreateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.CreateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().CreateClusterSearchDeploymentExecute(mock.Anything). + Return(updatingResp, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.InProgress, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + mockSearchAPI := mockadmin.NewAtlasSearchApi(t) + tc.mockSetup(mockSearchAPI) + + mockClient := &admin20250312010.APIClient{AtlasSearchApi: mockSearchAPI} + resource.InitEnvWithClient = func(req handler.Request, currentModel *resource.Model, requiredFields []string) (*admin20250312010.APIClient, *handler.ProgressEvent) { + return mockClient, nil + } + + event, err := resource.Create(tc.req, nil, createTestSearchDeploymentModel()) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, event.OperationStatus) + if tc.validateResult != nil { + tc.validateResult(t, event) + } + }) + } +} + +func TestReadWithMocks(t *testing.T) { + originalInitEnv := resource.InitEnvWithClient + defer func() { resource.InitEnvWithClient = originalInitEnv }() + + testCases := map[string]struct { + mockSetup func(*mockadmin.AtlasSearchApi) + expectedStatus handler.Status + }{ + "successfulRead": { + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + }, + "readNotFound": { + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) + }, + expectedStatus: handler.Failed, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + mockSearchAPI := mockadmin.NewAtlasSearchApi(t) + tc.mockSetup(mockSearchAPI) + + mockClient := &admin20250312010.APIClient{AtlasSearchApi: mockSearchAPI} + resource.InitEnvWithClient = func(req handler.Request, currentModel *resource.Model, requiredFields []string) (*admin20250312010.APIClient, *handler.ProgressEvent) { + return mockClient, nil + } + + event, err := resource.Read(handler.Request{}, nil, createTestSearchDeploymentModel()) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, event.OperationStatus) + }) + } +} + +func TestUpdateWithMocks(t *testing.T) { + originalInitEnv := resource.InitEnvWithClient + defer func() { resource.InitEnvWithClient = originalInitEnv }() + + testCases := map[string]struct { + mockSetup func(*mockadmin.AtlasSearchApi) + expectedStatus handler.Status + req handler.Request + }{ + "successfulUpdate": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + + m.EXPECT().UpdateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.UpdateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().UpdateClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 200}, nil) + + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.InProgress, + }, + "updateWithCallback": { + req: handler.Request{CallbackContext: map[string]interface{}{"callbackSearchDeployment": true}}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Success, + }, + "updateWithError": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + m.EXPECT().UpdateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.UpdateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().UpdateClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 500}, fmt.Errorf("update failed")) + }, + expectedStatus: handler.Failed, + }, + "updateResourceNotFound": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: http.StatusNotFound}, fmt.Errorf("not found")) + }, + expectedStatus: handler.Failed, + }, + "updateResourceNilResponse": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + respWithNoID := &admin20250312010.ApiSearchDeploymentResponse{} + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(respWithNoID, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.Failed, + }, + "updateGetErrorAfterUpdate": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + checkResp := createTestSearchDeploymentResponse() + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(checkResp, &http.Response{StatusCode: 200}, nil).Once() + + m.EXPECT().UpdateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.UpdateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().UpdateClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 200}, nil) + + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 500}, fmt.Errorf("get failed")) + }, + expectedStatus: handler.InProgress, + }, + "updateGetNilResponseAfterUpdate": { + req: handler.Request{}, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + checkResp := createTestSearchDeploymentResponse() + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(checkResp, &http.Response{StatusCode: 200}, nil).Once() + + m.EXPECT().UpdateClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.UpdateClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().UpdateClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 200}, nil) + + respWithNoID := &admin20250312010.ApiSearchDeploymentResponse{} + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(respWithNoID, &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.InProgress, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + mockSearchAPI := mockadmin.NewAtlasSearchApi(t) + tc.mockSetup(mockSearchAPI) + + mockClient := &admin20250312010.APIClient{AtlasSearchApi: mockSearchAPI} + resource.InitEnvWithClient = func(req handler.Request, currentModel *resource.Model, requiredFields []string) (*admin20250312010.APIClient, *handler.ProgressEvent) { + return mockClient, nil + } + + event, err := resource.Update(tc.req, nil, createTestSearchDeploymentModel()) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, event.OperationStatus) + }) + } +} + +func TestDeleteWithMocks(t *testing.T) { + originalInitEnv := resource.InitEnvWithClient + defer func() { resource.InitEnvWithClient = originalInitEnv }() + + testCases := map[string]struct { + mockSetup func(*mockadmin.AtlasSearchApi) + currentModel *resource.Model + prevModel *resource.Model + expectedStatus handler.Status + req handler.Request + }{ + "successfulDelete": { + req: handler.Request{}, + currentModel: createTestSearchDeploymentModel(), + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().DeleteClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.DeleteClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().DeleteClusterSearchDeploymentExecute(mock.Anything). + Return(&http.Response{StatusCode: 200}, nil) + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.InProgress, + }, + "deleteWithCallback": { + req: handler.Request{CallbackContext: map[string]interface{}{"callbackSearchDeployment": true}}, + currentModel: createTestSearchDeploymentModel(), + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) + }, + expectedStatus: handler.Success, + }, + "deleteWithError": { + req: handler.Request{}, + currentModel: createTestSearchDeploymentModel(), + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().DeleteClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.DeleteClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().DeleteClusterSearchDeploymentExecute(mock.Anything). + Return(&http.Response{StatusCode: 500}, fmt.Errorf("delete failed")) + }, + expectedStatus: handler.Failed, + }, + "deleteResourceNotFoundInGet": { + req: handler.Request{}, + currentModel: createTestSearchDeploymentModel(), + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().DeleteClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.DeleteClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().DeleteClusterSearchDeploymentExecute(mock.Anything). + Return(&http.Response{StatusCode: 200}, nil) + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(nil, &http.Response{StatusCode: 404}, fmt.Errorf("not found")) + }, + expectedStatus: handler.InProgress, + }, + "deleteWithNilCurrentModelUsesPrevModel": { + req: handler.Request{}, + currentModel: nil, + prevModel: createTestSearchDeploymentModel(), + mockSetup: func(m *mockadmin.AtlasSearchApi) { + m.EXPECT().DeleteClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.DeleteClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().DeleteClusterSearchDeploymentExecute(mock.Anything). + Return(&http.Response{StatusCode: 200}, nil) + m.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: m}) + m.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(createTestSearchDeploymentResponse(), &http.Response{StatusCode: 200}, nil) + }, + expectedStatus: handler.InProgress, + }, + "deleteWithNilCurrentModelNoProjectID": { + req: handler.Request{}, + currentModel: &resource.Model{ + ClusterName: util.StringPtr("test-cluster"), + }, + prevModel: nil, + mockSetup: func(m *mockadmin.AtlasSearchApi) { + }, + expectedStatus: handler.Failed, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + mockSearchAPI := mockadmin.NewAtlasSearchApi(t) + tc.mockSetup(mockSearchAPI) + + mockClient := &admin20250312010.APIClient{AtlasSearchApi: mockSearchAPI} + + if tc.expectedStatus != handler.Failed || name != "deleteWithNilCurrentModelNoProjectID" { + resource.InitEnvWithClient = func(req handler.Request, currentModel *resource.Model, requiredFields []string) (*admin20250312010.APIClient, *handler.ProgressEvent) { + return mockClient, nil + } + } else { + resource.InitEnvWithClient = originalInitEnv + } + + currentModel := tc.currentModel + if currentModel == nil { + currentModel = createTestSearchDeploymentModel() + } + event, err := resource.Delete(tc.req, tc.prevModel, currentModel) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, event.OperationStatus) + }) + } +} diff --git a/cfn-resources/search-deployment/cmd/resource/state_transition.go b/cfn-resources/search-deployment/cmd/resource/state_transition.go index 9d4e90e0d..43f4f3e3e 100644 --- a/cfn-resources/search-deployment/cmd/resource/state_transition.go +++ b/cfn-resources/search-deployment/cmd/resource/state_transition.go @@ -19,36 +19,58 @@ import ( "net/http" "strings" + admin20250312010 "go.mongodb.org/atlas-sdk/v20250312010/admin" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" "github.com/mongodb/mongodbatlas-cloudformation-resources/util" "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" "github.com/mongodb/mongodbatlas-cloudformation-resources/util/progressevent" - admin20231115014 "go.mongodb.org/atlas-sdk/v20231115014/admin" ) -func HandleStateTransition(connV2 admin20231115014.APIClient, currentModel *Model, targetState string) handler.ProgressEvent { +func ValidateProgress(connV2 admin20250312010.APIClient, currentModel *Model, isDelete bool) handler.ProgressEvent { projectID := util.SafeString(currentModel.ProjectId) clusterName := util.SafeString(currentModel.ClusterName) - apiResp, resp, err := connV2.AtlasSearchApi.GetAtlasSearchDeployment(context.Background(), projectID, clusterName).Execute() + + apiResp, resp, err := connV2.AtlasSearchApi.GetClusterSearchDeployment(context.Background(), projectID, clusterName).Execute() + + notFound := resp != nil && resp.StatusCode == http.StatusNotFound + doesNotExist := resp != nil && resp.StatusCode == http.StatusBadRequest && err != nil && + strings.Contains(err.Error(), "ATLAS_SEARCH_DEPLOYMENT_DOES_NOT_EXIST") + if err != nil { - if targetState == constants.DeletedState && resp.StatusCode == http.StatusBadRequest && strings.Contains(err.Error(), SearchDeploymentDoesNotExistsError) { + if isDelete && (notFound || doesNotExist) { return handler.ProgressEvent{ OperationStatus: handler.Success, - ResourceModel: nil, Message: constants.Complete, } } return progressevent.GetFailedEventByResponse(err.Error(), resp) } - newModel := NewCFNSearchDeployment(currentModel, apiResp) - if util.SafeString(newModel.StateName) == targetState { + state := constants.DeletedState + if apiResp != nil && apiResp.StateName != nil { + state = *apiResp.StateName + } + targetState := constants.IdleState + if isDelete { + targetState = constants.DeletedState + } + + if state != targetState { + return inProgressEvent(constants.Pending, currentModel, apiResp) + } + + if isDelete { return handler.ProgressEvent{ OperationStatus: handler.Success, - ResourceModel: newModel, Message: constants.Complete, } } - return inProgressEvent(constants.Pending, &newModel) + newModel := NewCFNSearchDeployment(currentModel, apiResp) + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + ResourceModel: &newModel, + } } diff --git a/cfn-resources/search-deployment/cmd/resource/state_transition_test.go b/cfn-resources/search-deployment/cmd/resource/state_transition_test.go index db99f1800..793d5fdfb 100644 --- a/cfn-resources/search-deployment/cmd/resource/state_transition_test.go +++ b/cfn-resources/search-deployment/cmd/resource/state_transition_test.go @@ -16,104 +16,195 @@ package resource_test import ( "errors" + "fmt" "net/http" "testing" "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" "github.com/mongodb/mongodbatlas-cloudformation-resources/search-deployment/cmd/resource" - "github.com/mongodb/mongodbatlas-cloudformation-resources/testutil/mocksvc" "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - admin20231115014 "go.mongodb.org/atlas-sdk/v20231115014/admin" + "github.com/stretchr/testify/require" + admin20250312010 "go.mongodb.org/atlas-sdk/v20250312010/admin" + "go.mongodb.org/atlas-sdk/v20250312010/mockadmin" +) + +const ( + stProfile = "customProfile" + stDummyProjectID = "222222222222222222222222" + stClusterName = "Cluster0" ) type stateTransitionTestCase struct { - name string - respModel *admin20231115014.ApiSearchDeploymentResponse + respModel *admin20250312010.ApiSearchDeploymentResponse respHTTP *http.Response respError error - targetState string + validateResult func(t *testing.T, event handler.ProgressEvent) + name string expectedEventStatus handler.Status + isDelete bool } -var prevModel = resource.Model{ - Profile: admin20231115014.PtrString(profile), - ClusterName: admin20231115014.PtrString(clusterName), - ProjectId: admin20231115014.PtrString(dummyProjectID), +func createTestModel(projectID, clusterName, profile string) resource.Model { + return resource.Model{ + Profile: admin20250312010.PtrString(profile), + ClusterName: admin20250312010.PtrString(clusterName), + ProjectId: admin20250312010.PtrString(projectID), + } } func TestStateTransitionProgressEvents(t *testing.T) { testCases := []stateTransitionTestCase{ { - name: "State in WORKING with target IDLE should return in progress event", - respModel: &admin20231115014.ApiSearchDeploymentResponse{ - StateName: admin20231115014.PtrString("UPDATING"), - }, - respHTTP: &http.Response{ - StatusCode: 200, + name: "State UPDATING with target IDLE returns in progress", + respModel: &admin20250312010.ApiSearchDeploymentResponse{ + Id: admin20250312010.PtrString("test-id-123"), + StateName: admin20250312010.PtrString("UPDATING"), + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{ + {InstanceSize: "S20_HIGHCPU_NVME", NodeCount: 2}, + }, }, - respError: nil, - targetState: constants.IdleState, + respHTTP: &http.Response{StatusCode: 200}, + isDelete: false, expectedEventStatus: handler.InProgress, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, constants.Pending, event.Message) + assert.NotNil(t, event.ResourceModel) + }, }, { - name: "State in IDLE with target IDLE should return success event", - respModel: &admin20231115014.ApiSearchDeploymentResponse{ - StateName: admin20231115014.PtrString("IDLE"), + name: "State IDLE with target IDLE returns success", + respModel: &admin20250312010.ApiSearchDeploymentResponse{ + Id: admin20250312010.PtrString("test-id-456"), + StateName: admin20250312010.PtrString("IDLE"), + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{ + {InstanceSize: "S20_HIGHCPU_NVME", NodeCount: 2}, + }, }, - respHTTP: &http.Response{ - StatusCode: 200, + respHTTP: &http.Response{StatusCode: 200}, + isDelete: false, + expectedEventStatus: handler.Success, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, constants.Complete, event.Message) + model := event.ResourceModel.(*resource.Model) + assert.Equal(t, "IDLE", *model.StateName) + assert.Equal(t, stProfile, *model.Profile) + assert.Equal(t, stClusterName, *model.ClusterName) }, - respError: nil, - targetState: constants.IdleState, + }, + { + name: "400 with DoesNotExist and target DELETED returns success", + respHTTP: &http.Response{StatusCode: 400}, + respError: errors.New(resource.SearchDeploymentDoesNotExistsErrorAPI), + isDelete: true, expectedEventStatus: handler.Success, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Equal(t, constants.Complete, event.Message) + assert.Nil(t, event.ResourceModel) + }, }, { - name: "400 response with target DELETED should return success event", - respModel: nil, - respHTTP: &http.Response{ - StatusCode: 400, + name: "State IDLE with target DELETED returns in progress", + respModel: &admin20250312010.ApiSearchDeploymentResponse{ + Id: admin20250312010.PtrString("test-id-101"), + StateName: admin20250312010.PtrString("IDLE"), + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{{InstanceSize: "S20_HIGHCPU_NVME", NodeCount: 2}}, }, - respError: errors.New(resource.SearchDeploymentDoesNotExistsError), - targetState: constants.DeletedState, - expectedEventStatus: handler.Success, + respHTTP: &http.Response{StatusCode: 200}, + isDelete: true, + expectedEventStatus: handler.InProgress, }, { - name: "State in WORKING with target DELETED should return in progress event", - respModel: &admin20231115014.ApiSearchDeploymentResponse{ - StateName: admin20231115014.PtrString("UPDATING"), + name: "500 error returns failed", + respHTTP: &http.Response{StatusCode: 500}, + respError: errors.New("internal server error"), + isDelete: false, + expectedEventStatus: handler.Failed, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + assert.Contains(t, event.Message, "internal server error") }, - respHTTP: &http.Response{ - StatusCode: 200, + }, + { + name: "404 error returns failed", + respHTTP: &http.Response{StatusCode: 404}, + respError: errors.New("resource not found"), + isDelete: false, + expectedEventStatus: handler.Failed, + }, + { + name: "400 without specific error code returns failed", + respHTTP: &http.Response{StatusCode: 400}, + respError: errors.New("bad request"), + isDelete: true, + expectedEventStatus: handler.Failed, + }, + { + name: "Response with EncryptionAtRestProvider includes it in model", + respModel: &admin20250312010.ApiSearchDeploymentResponse{ + Id: admin20250312010.PtrString("test-id-404"), + StateName: admin20250312010.PtrString("IDLE"), + EncryptionAtRestProvider: admin20250312010.PtrString("AWS"), + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{{InstanceSize: "S20_HIGHCPU_NVME", NodeCount: 2}}, + }, + respHTTP: &http.Response{StatusCode: 200}, + isDelete: false, + expectedEventStatus: handler.Success, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + model := event.ResourceModel.(*resource.Model) + require.NotNil(t, model.EncryptionAtRestProvider) + assert.Equal(t, "AWS", *model.EncryptionAtRestProvider) }, - respError: nil, - targetState: constants.DeletedState, - expectedEventStatus: handler.InProgress, }, { - name: "State in IDLE with target DELETED should return in progress event", - respModel: &admin20231115014.ApiSearchDeploymentResponse{ - StateName: admin20231115014.PtrString("IDLE"), + name: "Empty specs array handled correctly", + respModel: &admin20250312010.ApiSearchDeploymentResponse{ + Id: admin20250312010.PtrString("test-id-505"), + StateName: admin20250312010.PtrString("IDLE"), + Specs: &[]admin20250312010.ApiSearchDeploymentSpec{}, }, - respHTTP: &http.Response{ - StatusCode: 200, + respHTTP: &http.Response{StatusCode: 200}, + isDelete: false, + expectedEventStatus: handler.Success, + validateResult: func(t *testing.T, event handler.ProgressEvent) { + t.Helper() + model := event.ResourceModel.(*resource.Model) + assert.Empty(t, model.Specs) }, - respError: nil, - targetState: constants.DeletedState, - expectedEventStatus: handler.InProgress, + }, + { + name: "503 service unavailable returns failed", + respHTTP: &http.Response{StatusCode: 503}, + respError: fmt.Errorf("service unavailable"), + isDelete: false, + expectedEventStatus: handler.Failed, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - m := mocksvc.NewAtlasSearchApi(t) - m.EXPECT().GetAtlasSearchDeployment(mock.Anything, mock.Anything, mock.Anything).Return(admin20231115014.GetAtlasSearchDeploymentApiRequest{ApiService: m}).Once() - m.EXPECT().GetAtlasSearchDeploymentExecute(mock.Anything).Return(tc.respModel, tc.respHTTP, tc.respError).Once() + mockSearchAPI := mockadmin.NewAtlasSearchApi(t) + + req := admin20250312010.GetClusterSearchDeploymentApiRequest{ApiService: mockSearchAPI} + mockSearchAPI.EXPECT().GetClusterSearchDeployment(mock.Anything, mock.Anything, mock.Anything). + Return(req).Once() + mockSearchAPI.EXPECT().GetClusterSearchDeploymentExecute(mock.Anything). + Return(tc.respModel, tc.respHTTP, tc.respError).Once() + + client := admin20250312010.APIClient{AtlasSearchApi: mockSearchAPI} + testModel := createTestModel(stDummyProjectID, stClusterName, stProfile) + + eventResult := resource.ValidateProgress(client, &testModel, tc.isDelete) - client := admin20231115014.APIClient{AtlasSearchApi: m} - eventResult := resource.HandleStateTransition(client, &prevModel, tc.targetState) assert.Equal(t, tc.expectedEventStatus, eventResult.OperationStatus) + if tc.validateResult != nil { + tc.validateResult(t, eventResult) + } }) } } diff --git a/cfn-resources/search-deployment/docs/README.md b/cfn-resources/search-deployment/docs/README.md index ecacad0af..0053a2da8 100644 --- a/cfn-resources/search-deployment/docs/README.md +++ b/cfn-resources/search-deployment/docs/README.md @@ -96,3 +96,7 @@ Unique 24-hexadecimal digit string that identifies the search deployment. Human-readable label that indicates the current operating condition of this search deployment. +#### EncryptionAtRestProvider + +Cloud service provider that manages your customer keys to provide an additional layer of Encryption At Rest for the cluster. + diff --git a/cfn-resources/search-deployment/docs/apisearchdeploymentspec.md b/cfn-resources/search-deployment/docs/apisearchdeploymentspec.md index 80b413e8d..f8c76c71e 100644 --- a/cfn-resources/search-deployment/docs/apisearchdeploymentspec.md +++ b/cfn-resources/search-deployment/docs/apisearchdeploymentspec.md @@ -24,7 +24,7 @@ To declare this entity in your AWS CloudFormation template, use the following sy #### InstanceSize -Hardware specification for the search node instance sizes. The [MongoDB Atlas API](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-createatlassearchdeployment) describes the valid values. More details can also be found in the [Search Node Documentation](https://www.mongodb.com/docs/atlas/cluster-config/multi-cloud-distribution/#search-tier). +Hardware specification for the search node instance sizes. The [MongoDB Atlas API](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/#tag/Atlas-Search/operation/createAtlasSearchDeployment) describes the valid values. More details can also be found in the [Search Node Documentation](https://www.mongodb.com/docs/atlas/cluster-config/multi-cloud-distribution/#search-tier). _Required_: Yes diff --git a/cfn-resources/search-deployment/mongodb-atlas-searchdeployment.json b/cfn-resources/search-deployment/mongodb-atlas-searchdeployment.json index 948bb6717..e351336ab 100644 --- a/cfn-resources/search-deployment/mongodb-atlas-searchdeployment.json +++ b/cfn-resources/search-deployment/mongodb-atlas-searchdeployment.json @@ -71,11 +71,23 @@ "$ref": "#/definitions/ApiSearchDeploymentSpec", "type": "object" }, - "description": "List of settings that configure the search nodes for your cluster. This list is currently limited to defining a single element." + "description": "List of settings that configure the search nodes for your cluster. This list is currently limited to defining a single element.", + "minItems": 1, + "maxItems": 1 }, "StateName": { "type": "string", "description": "Human-readable label that indicates the current operating condition of this search deployment." + }, + "EncryptionAtRestProvider": { + "type": "string", + "description": "Cloud service provider that manages your customer keys to provide an additional layer of Encryption At Rest for the cluster.", + "enum": [ + "AWS", + "GCP", + "AZURE", + "NONE" + ] } }, "primaryIdentifier": [ @@ -96,7 +108,8 @@ ], "readOnlyProperties": [ "/properties/Id", - "/properties/StateName" + "/properties/StateName", + "/properties/EncryptionAtRestProvider" ], "typeName": "MongoDB::Atlas::SearchDeployment", "documentationUrl": "https://github.com/mongodb/mongodbatlas-cloudformation-resources/blob/master/cfn-resources/search-deployment/README.md", diff --git a/cfn-resources/search-deployment/template.yml b/cfn-resources/search-deployment/template.yml index 402940f14..3e29b647b 100644 --- a/cfn-resources/search-deployment/template.yml +++ b/cfn-resources/search-deployment/template.yml @@ -4,7 +4,7 @@ Description: AWS SAM template for the MongoDB::Atlas::SearchDeployment resource Globals: Function: - Timeout: 180 # docker start-up times can be long for SAM CLI + Timeout: 900 # Search deployment operations can take 15+ minutes (UPDATING state transitions are long-running) MemorySize: 256 Resources: diff --git a/cfn-resources/search-deployment/test/cfn-test-create-inputs.sh b/cfn-resources/search-deployment/test/cfn-test-create-inputs.sh index 8a1e1220d..76d68a12c 100755 --- a/cfn-resources/search-deployment/test/cfn-test-create-inputs.sh +++ b/cfn-resources/search-deployment/test/cfn-test-create-inputs.sh @@ -10,10 +10,11 @@ set -o pipefail function usage { echo "usage:$0 " - echo "Creates a new project and an Cluster for testing" + echo "Generates test input files for search deployment" + exit 0 } -if [ "$#" -ne 2 ]; then usage; fi +if [ "$#" -ne 1 ]; then usage; fi if [[ "$*" == help ]]; then usage; fi rm -rf inputs @@ -27,22 +28,43 @@ if [ ${MONGODB_ATLAS_PROFILE+x} ]; then fi projectName="${1}" -clusterName="${projectName}" -projectId=$(atlas projects list --output json | jq --arg NAME "${projectName}" -r '.results[] | select(.name==$NAME) | .id') -if [ -z "$projectId" ]; then - projectId=$(atlas projects create "${projectName}" --output=json | jq -r '.id') - - echo -e "Created project \"${projectName}\" with id: ${projectId}\n" +# Check if MONGODB_ATLAS_PROJECT_ID is provided - use it directly if available +if [ ${MONGODB_ATLAS_PROJECT_ID+x} ] && [ -n "${MONGODB_ATLAS_PROJECT_ID}" ]; then + projectId="${MONGODB_ATLAS_PROJECT_ID}" + echo -e "Using provided project ID: ${projectId}\n" else - echo -e "Found project \"${projectName}\" with id: ${projectId}\n" + # Follow FlexCluster pattern: lookup by name, create if not found + projectId=$(atlas projects list --output json | jq --arg NAME "${projectName}" -r '.results[] | select(.name==$NAME) | .id') + if [ -z "$projectId" ]; then + projectId=$(atlas projects create "${projectName}" --output=json | jq -r '.id') + echo -e "Created project \"${projectName}\" with id: ${projectId}\n" + else + echo -e "FOUND project \"${projectName}\" with id: ${projectId}\n" + fi fi -clusterId=$(atlas clusters list --projectId "${projectId}" --output json | jq --arg NAME "${clusterName}" -r '.results[]? | select(.name==$NAME) | .id') -if [ -z "$clusterId" ]; then - atlas clusters create "${clusterName}" --projectId "${projectId}" --provider AWS --region US_EAST_1 --tier M10 --mdbVersion 7.0 --output=json - atlas clusters watch "${clusterName}" --projectId "${projectId}" - echo -e "Created Cluster \"${clusterName}\"" +# Check if MONGODB_ATLAS_CLUSTER_NAME is provided - use it directly if available +if [ ${MONGODB_ATLAS_CLUSTER_NAME+x} ] && [ -n "${MONGODB_ATLAS_CLUSTER_NAME}" ]; then + clusterName="${MONGODB_ATLAS_CLUSTER_NAME}" + echo -e "Using provided cluster name: ${clusterName}\n" + # Verify cluster exists + clusterId=$(atlas clusters list --projectId "${projectId}" --output json | jq --arg NAME "${clusterName}" -r '.results[]? | select(.name==$NAME) | .id') + if [ -z "$clusterId" ]; then + echo "ERROR: Cluster '${clusterName}' not found in project ${projectId}" + exit 1 + fi + echo -e "Found Cluster \"${clusterName}\" (ID: ${clusterId})\n" +else + clusterName="cfn-test-bot-$(date +%s)-$RANDOM" + echo "clusterName: $clusterName" + + clusterId=$(atlas clusters list --projectId "${projectId}" --output json | jq --arg NAME "${clusterName}" -r '.results[]? | select(.name==$NAME) | .id') + if [ -z "$clusterId" ]; then + atlas clusters create "${clusterName}" --projectId "${projectId}" --provider AWS --region US_EAST_1 --tier M10 --mdbVersion 7.0 --output=json + atlas clusters watch "${clusterName}" --projectId "${projectId}" + echo -e "Created Cluster \"${clusterName}\"" + fi fi WORDTOREMOVE="template." diff --git a/cfn-resources/search-deployment/test/cfn-test-delete-inputs.sh b/cfn-resources/search-deployment/test/cfn-test-delete-inputs.sh index f19698dd6..9bc5cf23d 100755 --- a/cfn-resources/search-deployment/test/cfn-test-delete-inputs.sh +++ b/cfn-resources/search-deployment/test/cfn-test-delete-inputs.sh @@ -11,6 +11,17 @@ function usage { clusterName=$(jq -r '.ClusterName' ./inputs/inputs_1_create.json) projectId=$(jq -r '.ProjectId' ./inputs/inputs_1_create.json) +# TEMPORARY: Skip deletion of test cluster/project (only for today's testing - 2025-12-29) +# TODO: Remove this after testing is complete +# Note: TEST_PROJECT and TEST_CLUSTER should be set via environment variables if needed +# This section can be removed after testing is complete +if [[ -n "${TEST_CLUSTER:-}" && -n "${TEST_PROJECT:-}" ]]; then + if [[ "$clusterName" == "${TEST_CLUSTER}" && "$projectId" == "${TEST_PROJECT}" ]]; then + echo "SKIPPING deletion of test cluster '$clusterName' and project '$projectId' (preserved for testing)" + exit 0 + fi +fi + #delete Cluster if atlas clusters delete "$clusterName" --projectId "${projectId}" --force; then echo "$clusterName cluster deletion OK" @@ -30,9 +41,13 @@ while [[ "${status}" == "DELETING" ]]; do echo "status: ${status}" done -#delete project -if atlas projects delete "$projectId" --force; then - echo "$projectId project deletion OK" +#delete project (skip if it's the test project) +if [[ -n "${TEST_PROJECT:-}" && "$projectId" == "$TEST_PROJECT" ]]; then + echo "SKIPPING deletion of test project '$projectId' (preserved for testing)" else - (echo "Failed cleaning project:$projectId" && exit 1) + if atlas projects delete "$projectId" --force; then + echo "$projectId project deletion OK" + else + (echo "Failed cleaning project:$projectId" && exit 1) + fi fi diff --git a/cfn-resources/search-deployment/test/searchdeployment.sample-cfn-request.json b/cfn-resources/search-deployment/test/searchdeployment.sample-cfn-request.json index 018d5efda..5fa30e38c 100644 --- a/cfn-resources/search-deployment/test/searchdeployment.sample-cfn-request.json +++ b/cfn-resources/search-deployment/test/searchdeployment.sample-cfn-request.json @@ -6,7 +6,7 @@ "Specs": [ { "InstanceSize": "S30_HIGHCPU_NVME", - "NodeCount": 3 + "NodeCount": "3" } ] },