diff --git a/.github/workflows/contract-testing.yaml b/.github/workflows/contract-testing.yaml index 8ae429783..4820758d1 100644 --- a/.github/workflows/contract-testing.yaml +++ b/.github/workflows/contract-testing.yaml @@ -30,6 +30,7 @@ jobs: search-deployment: ${{ steps.filter.outputs.search-deployment }} stream-connection: ${{ steps.filter.outputs.stream-connection }} stream-instance: ${{ steps.filter.outputs.stream-instance }} + stream-workspace: ${{ steps.filter.outputs.stream-workspace }} steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 if: ${{ github.event_name == 'push' }} @@ -75,6 +76,8 @@ jobs: - 'cfn-resources/stream-connection/**' stream-instance: - 'cfn-resources/stream-instance/**' + stream-workspace: + - 'cfn-resources/stream-workspace/**' access-list-api-key: needs: change-detection if: ${{ needs.change-detection.outputs.access-list-api-key == 'true' }} @@ -854,3 +857,44 @@ jobs: cat inputs/inputs_1_create.json make run-contract-testing make delete-test-resources + stream-workspace: + needs: change-detection + if: ${{ needs.change-detection.outputs.stream-workspace == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c + with: + go-version-file: 'cfn-resources/go.mod' + - name: setup Atlas CLI + uses: mongodb/atlas-github-action@e3c9e0204659bafbb3b65e1eb1ee745cca0e9f3b + - uses: aws-actions/setup-sam@c2a20b1822cc4a6bc594ff7f1dbb658758e383c3 + with: + use-installer: true + - uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_TEST_ENV }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_TEST_ENV }} + aws-region: eu-west-1 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 + with: + python-version: '3.9' + cache: 'pip' # caching pip dependencies + - run: pip install cloudformation-cli cloudformation-cli-go-plugin + - name: Run the Contract test + shell: bash + env: + MONGODB_ATLAS_PUBLIC_API_KEY: ${{ secrets.CLOUD_DEV_PUBLIC_KEY }} + MONGODB_ATLAS_PRIVATE_API_KEY: ${{ secrets.CLOUD_DEV_PRIVATE_KEY }} + MONGODB_ATLAS_ORG_ID: ${{ secrets.CLOUD_DEV_ORG_ID }} + MONGODB_ATLAS_OPS_MANAGER_URL: ${{ vars.MONGODB_ATLAS_BASE_URL }} + MONGODB_ATLAS_PROFILE: cfn-cloud-dev-github-action + run: | + cd cfn-resources/stream-workspace + make create-test-resources + + cat inputs/inputs_1_create.json + cat inputs/inputs_1_update.json + + make run-contract-testing + make delete-test-resources \ No newline at end of file diff --git a/cfn-resources/stream-workspace/.rpdk-config b/cfn-resources/stream-workspace/.rpdk-config new file mode 100644 index 000000000..71b0b45c9 --- /dev/null +++ b/cfn-resources/stream-workspace/.rpdk-config @@ -0,0 +1,22 @@ +{ + "artifact_type": "RESOURCE", + "typeName": "MongoDB::Atlas::StreamWorkspace", + "language": "go", + "runtime": "provided.al2", + "entrypoint": "bootstrap", + "testEntrypoint": "bootstrap", + "settings": { + "version": false, + "subparser_name": null, + "verbose": 0, + "force": false, + "type_name": "MongoDB::Atlas::StreamWorkspace", + "artifact_type": null, + "endpoint_url": null, + "region": null, + "target_schemas": [], + "profile": null, + "import_path": "github.com/mongodb/mongodbatlas-cloudformation-resources/stream-workspace", + "protocolVersion": "2.0.0" + } +} diff --git a/cfn-resources/stream-workspace/Makefile b/cfn-resources/stream-workspace/Makefile new file mode 100644 index 000000000..296ff567e --- /dev/null +++ b/cfn-resources/stream-workspace/Makefile @@ -0,0 +1,33 @@ +.PHONY: build test clean +tags=logging callback metrics scheduler +cgo=0 +goos=linux +goarch=amd64 +CFNREP_GIT_SHA?=$(shell git rev-parse HEAD) +ldXflags=-s -w -X github.com/mongodb/mongodbatlas-cloudformation-resources/util.defaultLogLevel=info -X github.com/mongodb/mongodbatlas-cloudformation-resources/version.Version=${CFNREP_GIT_SHA} +ldXflagsD=-X github.com/mongodb/mongodbatlas-cloudformation-resources/util.defaultLogLevel=debug -X github.com/mongodb/mongodbatlas-cloudformation-resources/version.Version=${CFNREP_GIT_SHA} + +build: + cfn generate + env GOOS=$(goos) CGO_ENABLED=$(cgo) GOARCH=$(goarch) go build -ldflags="$(ldXflags)" -tags="$(tags)" -o bin/bootstrap cmd/main.go + +debug: + cfn generate + env GOOS=$(goos) CGO_ENABLED=$(cgo) GOARCH=$(goarch) go build -ldflags="$(ldXflagsD)" -tags="$(tags)" -o bin/bootstrap cmd/main.go + +clean: + rm -rf bin + +create-test-resources: + @echo "==> Creating test files for contract testing" + ./test/contract-testing/cfn-test-create-inputs.sh + +delete-test-resources: + @echo "==> Delete test resources used for contract testing" + ./test/cfn-test-delete-inputs.sh + +run-contract-testing: + @echo "==> Run contract testing" + make build + sam local start-lambda & + cfn test --function-name TestEntrypoint --verbose diff --git a/cfn-resources/stream-workspace/README.md b/cfn-resources/stream-workspace/README.md new file mode 100644 index 000000000..484467009 --- /dev/null +++ b/cfn-resources/stream-workspace/README.md @@ -0,0 +1,18 @@ +# MongoDB::Atlas::StreamWorkspace + +## Description + +Resource for managing [Stream Workspaces](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/group/endpoint-streams). + +## Requirements + +Set up an AWS profile to securely give CloudFormation access to your Atlas credentials. +For instructions on setting up a profile, [see here](/README.md#mongodb-atlas-api-keys-credential-management). + +## Attributes and Parameters + +See the [resource docs](docs/README.md). Also refer [AWS security best practices for CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/security-best-practices.html#creds) to manage credentials. + +## CloudFormation Examples + +See the examples [CFN Template](/examples/stream-workspace/stream-workspace.json) for example resource. diff --git a/cfn-resources/stream-workspace/cmd/main.go b/cfn-resources/stream-workspace/cmd/main.go new file mode 100644 index 000000000..b0feb978b --- /dev/null +++ b/cfn-resources/stream-workspace/cmd/main.go @@ -0,0 +1,85 @@ +// Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. +package main + +import ( + "errors" + "fmt" + "log" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn" + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/mongodb/mongodbatlas-cloudformation-resources/stream-workspace/cmd/resource" +) + +// Handler is a container for the CRUDL actions exported by resources +type Handler struct{} + +// Create wraps the related Create function exposed by the resource code +func (r *Handler) Create(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Create) +} + +// Read wraps the related Read function exposed by the resource code +func (r *Handler) Read(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Read) +} + +// Update wraps the related Update function exposed by the resource code +func (r *Handler) Update(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Update) +} + +// Delete wraps the related Delete function exposed by the resource code +func (r *Handler) Delete(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.Delete) +} + +// List wraps the related List function exposed by the resource code +func (r *Handler) List(req handler.Request) handler.ProgressEvent { + return wrap(req, resource.List) +} + +// main is the entry point of the application. +func main() { + cfn.Start(&Handler{}) +} + +type handlerFunc func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error) + +func wrap(req handler.Request, f handlerFunc) (response handler.ProgressEvent) { + defer func() { + // Catch any panics and return a failed ProgressEvent + if r := recover(); r != nil { + err, ok := r.(error) + if !ok { + err = errors.New(fmt.Sprint(r)) + } + + log.Printf("Trapped error in handler: %v", err) + + response = handler.NewFailedEvent(err) + } + }() + + // Populate the previous model + prevModel := &resource.Model{} + if err := req.UnmarshalPrevious(prevModel); err != nil { + log.Printf("Error unmarshaling prev model: %v", err) + return handler.NewFailedEvent(err) + } + + // Populate the current model + currentModel := &resource.Model{} + if err := req.Unmarshal(currentModel); err != nil { + log.Printf("Error unmarshaling model: %v", err) + return handler.NewFailedEvent(err) + } + + response, err := f(req, prevModel, currentModel) + if err != nil { + log.Printf("Error returned from handler function: %v", err) + return handler.NewFailedEvent(err) + } + + return response +} diff --git a/cfn-resources/stream-workspace/cmd/resource/config.go b/cfn-resources/stream-workspace/cmd/resource/config.go new file mode 100644 index 000000000..4d9eb7831 --- /dev/null +++ b/cfn-resources/stream-workspace/cmd/resource/config.go @@ -0,0 +1,19 @@ +// Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. +// Updates to this type are made my editing the schema file and executing the 'generate' command. +package resource + +import "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + +// TypeConfiguration is autogenerated from the json schema +type TypeConfiguration struct { +} + +// Configuration returns a resource's configuration. +func Configuration(req handler.Request) (*TypeConfiguration, error) { + // Populate the type configuration + typeConfig := &TypeConfiguration{} + if err := req.UnmarshalTypeConfig(typeConfig); err != nil { + return typeConfig, err + } + return typeConfig, nil +} diff --git a/cfn-resources/stream-workspace/cmd/resource/mappings.go b/cfn-resources/stream-workspace/cmd/resource/mappings.go new file mode 100644 index 000000000..c872f5976 --- /dev/null +++ b/cfn-resources/stream-workspace/cmd/resource/mappings.go @@ -0,0 +1,98 @@ +// Copyright 2026 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 + +import "go.mongodb.org/atlas-sdk/v20250312012/admin" + +func NewStreamWorkspaceCreateReq(model *Model) *admin.StreamsTenant { + if model == nil { + return nil + } + dataProcessRegion := *model.DataProcessRegion + streamTenant := &admin.StreamsTenant{ + Name: model.WorkspaceName, + GroupId: model.ProjectId, + DataProcessRegion: &admin.StreamsDataProcessRegion{ + CloudProvider: *dataProcessRegion.CloudProvider, + Region: *dataProcessRegion.Region, + }, + } + if streamConfig := model.StreamConfig; streamConfig != nil { + streamTenant.StreamConfig = &admin.StreamConfig{} + if tier := streamConfig.Tier; tier != nil { + streamTenant.StreamConfig.Tier = tier + } + if maxTierSize := streamConfig.MaxTierSize; maxTierSize != nil { + streamTenant.StreamConfig.MaxTierSize = maxTierSize + } + } + return streamTenant +} + +func NewStreamWorkspaceUpdateReq(model *Model) *admin.StreamsTenantUpdateRequest { + if model == nil || model.DataProcessRegion == nil { + return nil + } + dataProcessRegion := *model.DataProcessRegion + if dataProcessRegion.Region == nil { + return nil + } + // CloudFormation is AWS-only, so CloudProvider is always AWS + cloudProvider := CloudProvider + return &admin.StreamsTenantUpdateRequest{ + CloudProvider: &cloudProvider, + Region: dataProcessRegion.Region, + } +} + +func newModelDataRegion(dataProcessRegion *admin.StreamsDataProcessRegion) *StreamsDataProcessRegion { + return &StreamsDataProcessRegion{ + CloudProvider: &dataProcessRegion.CloudProvider, + Region: &dataProcessRegion.Region, + } +} + +func newModelStreamConfig(streamConfig *admin.StreamConfig) *StreamConfig { + if streamConfig == nil { + return nil + } + modelConfig := &StreamConfig{} + if streamConfig.Tier != nil { + modelConfig.Tier = streamConfig.Tier + } + if streamConfig.MaxTierSize != nil { + modelConfig.MaxTierSize = streamConfig.MaxTierSize + } + return modelConfig +} + +func GetStreamWorkspaceModel(streamTenant *admin.StreamsTenant, currentModel *Model) *Model { + model := new(Model) + + if currentModel != nil { + model = currentModel + } + + if streamTenant != nil { + model.WorkspaceName = streamTenant.Name + model.DataProcessRegion = newModelDataRegion(streamTenant.DataProcessRegion) + model.StreamConfig = newModelStreamConfig(streamTenant.StreamConfig) + model.ProjectId = streamTenant.GroupId + model.Id = streamTenant.Id + model.Hostnames = streamTenant.GetHostnames() + } + + return model +} diff --git a/cfn-resources/stream-workspace/cmd/resource/mappings_test.go b/cfn-resources/stream-workspace/cmd/resource/mappings_test.go new file mode 100644 index 000000000..3ab17861a --- /dev/null +++ b/cfn-resources/stream-workspace/cmd/resource/mappings_test.go @@ -0,0 +1,184 @@ +// Copyright 2026 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 ( + "testing" + + "github.com/mongodb/mongodbatlas-cloudformation-resources/stream-workspace/cmd/resource" + "github.com/stretchr/testify/assert" + "go.mongodb.org/atlas-sdk/v20250312012/admin" +) + +var ( + workspaceName = "name" + projectID = "projectId" + cloudProvider = resource.CloudProvider + region = "VIRGINIA_USA" + tier = "SP30" + maxTierSize = "SP50" +) + +func TestNewStreamWorkspaceCreateReq(t *testing.T) { + testCases := []struct { + input *resource.Model + expected *admin.StreamsTenant + name string + }{ + { + name: "Model with StreamConfig including Tier and MaxTierSize", + input: &resource.Model{ + WorkspaceName: &workspaceName, + ProjectId: &projectID, + DataProcessRegion: &resource.StreamsDataProcessRegion{ + CloudProvider: &cloudProvider, + Region: ®ion, + }, + StreamConfig: &resource.StreamConfig{ + Tier: &tier, + MaxTierSize: &maxTierSize, + }, + }, + expected: &admin.StreamsTenant{ + Name: &workspaceName, + GroupId: &projectID, + DataProcessRegion: &admin.StreamsDataProcessRegion{ + CloudProvider: cloudProvider, + Region: region, + }, + StreamConfig: &admin.StreamConfig{ + Tier: &tier, + MaxTierSize: &maxTierSize, + }, + }, + }, + { + name: "Model with StreamConfig with only Tier", + input: &resource.Model{ + WorkspaceName: &workspaceName, + ProjectId: &projectID, + DataProcessRegion: &resource.StreamsDataProcessRegion{ + CloudProvider: &cloudProvider, + Region: ®ion, + }, + StreamConfig: &resource.StreamConfig{ + Tier: &tier, + }, + }, + expected: &admin.StreamsTenant{ + Name: &workspaceName, + GroupId: &projectID, + DataProcessRegion: &admin.StreamsDataProcessRegion{ + CloudProvider: cloudProvider, + Region: region, + }, + StreamConfig: &admin.StreamConfig{ + Tier: &tier, + }, + }, + }, + { + name: "Model without StreamConfig", + input: &resource.Model{ + WorkspaceName: &workspaceName, + ProjectId: &projectID, + DataProcessRegion: &resource.StreamsDataProcessRegion{ + CloudProvider: &cloudProvider, + Region: ®ion, + }, + }, + expected: &admin.StreamsTenant{ + Name: &workspaceName, + GroupId: &projectID, + DataProcessRegion: &admin.StreamsDataProcessRegion{ + CloudProvider: cloudProvider, + Region: region, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := resource.NewStreamWorkspaceCreateReq(tc.input) + assert.Equal(t, tc.expected, result, "created model did not match expected output") + }) + } +} + +func TestNewStreamWorkspaceUpdateReq(t *testing.T) { + newRegion := "OREGON_USA" + awsProvider := resource.CloudProvider + testCases := []struct { + input *resource.Model + expected *admin.StreamsTenantUpdateRequest + name string + }{ + { + name: "Model with DataProcessRegion and Region", + input: &resource.Model{ + DataProcessRegion: &resource.StreamsDataProcessRegion{ + CloudProvider: &cloudProvider, + Region: &newRegion, + }, + }, + expected: &admin.StreamsTenantUpdateRequest{ + CloudProvider: &awsProvider, + Region: &newRegion, + }, + }, + { + name: "Model with DataProcessRegion but no CloudProvider (should still work)", + input: &resource.Model{ + DataProcessRegion: &resource.StreamsDataProcessRegion{ + Region: &newRegion, + }, + }, + expected: &admin.StreamsTenantUpdateRequest{ + CloudProvider: &awsProvider, + Region: &newRegion, + }, + }, + { + name: "Model is nil", + input: nil, + expected: nil, + }, + { + name: "Model with nil DataProcessRegion", + input: &resource.Model{ + DataProcessRegion: nil, + }, + expected: nil, + }, + { + name: "Model with DataProcessRegion but nil Region", + input: &resource.Model{ + DataProcessRegion: &resource.StreamsDataProcessRegion{ + CloudProvider: &cloudProvider, + Region: nil, + }, + }, + expected: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := resource.NewStreamWorkspaceUpdateReq(tc.input) + assert.Equal(t, tc.expected, result, "update request did not match expected output") + }) + } +} diff --git a/cfn-resources/stream-workspace/cmd/resource/model.go b/cfn-resources/stream-workspace/cmd/resource/model.go new file mode 100644 index 000000000..da4de0e4c --- /dev/null +++ b/cfn-resources/stream-workspace/cmd/resource/model.go @@ -0,0 +1,26 @@ +// Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. +// Updates to this type are made my editing the schema file and executing the 'generate' command. +package resource + +// Model is autogenerated from the json schema +type Model struct { + Profile *string `json:",omitempty"` + WorkspaceName *string `json:",omitempty"` + DataProcessRegion *StreamsDataProcessRegion `json:",omitempty"` + StreamConfig *StreamConfig `json:",omitempty"` + Id *string `json:",omitempty"` + ProjectId *string `json:",omitempty"` + Hostnames []string `json:",omitempty"` +} + +// StreamsDataProcessRegion is autogenerated from the json schema +type StreamsDataProcessRegion struct { + CloudProvider *string `json:",omitempty"` + Region *string `json:",omitempty"` +} + +// StreamConfig is autogenerated from the json schema +type StreamConfig struct { + Tier *string `json:",omitempty"` + MaxTierSize *string `json:",omitempty"` +} diff --git a/cfn-resources/stream-workspace/cmd/resource/resource.go b/cfn-resources/stream-workspace/cmd/resource/resource.go new file mode 100644 index 000000000..7fe4f1524 --- /dev/null +++ b/cfn-resources/stream-workspace/cmd/resource/resource.go @@ -0,0 +1,213 @@ +// Copyright 2026 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 + +import ( + "context" + "fmt" + "net/http" + + "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" + "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + + "github.com/mongodb/mongodbatlas-cloudformation-resources/util" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/constants" + progress_events "github.com/mongodb/mongodbatlas-cloudformation-resources/util/progressevent" + "github.com/mongodb/mongodbatlas-cloudformation-resources/util/validator" + "go.mongodb.org/atlas-sdk/v20250312012/admin" +) + +var CreateRequiredFields = []string{"WorkspaceName", constants.ProjectID, constants.DataProcessRegion} +var ReadRequiredFields = []string{"WorkspaceName", constants.ProjectID} +var UpdateRequiredFields = []string{"WorkspaceName", constants.ProjectID, constants.DataProcessRegion} +var DeleteRequiredFields = []string{"WorkspaceName", constants.ProjectID} +var ListRequiredFields = []string{constants.ProjectID} + +const ( + CloudProvider = "AWS" + DefaultItemsPerPage = 100 +) + +var InitEnvWithLatestClient = func(req handler.Request, currentModel *Model, requiredFields []string) (*admin.APIClient, *handler.ProgressEvent) { + util.SetupLogger("mongodb-atlas-stream-workspace") + + util.SetDefaultProfileIfNotDefined(¤tModel.Profile) + + if errEvent := validator.ValidateModel(requiredFields, currentModel); errEvent != nil { + return nil, errEvent + } + + client, peErr := util.NewAtlasClient(&req, currentModel.Profile) + if peErr != nil { + return nil, peErr + } + return client.AtlasSDK, nil +} + +func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + conn, peErr := InitEnvWithLatestClient(req, currentModel, CreateRequiredFields) + if peErr != nil { + return *peErr, nil + } + + ctx := context.Background() + + streamWorkspaceCreateReq := NewStreamWorkspaceCreateReq(currentModel) + + createdStreamWorkspace, resp, err := conn.StreamsApi.CreateStreamWorkspace(ctx, *currentModel.ProjectId, streamWorkspaceCreateReq).Execute() + if err != nil { + return HandleError(resp, constants.CREATE, err) + } + + model := GetStreamWorkspaceModel(createdStreamWorkspace, currentModel) + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: "Create Completed", + ResourceModel: model, + }, nil +} + +func Read(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + conn, peErr := InitEnvWithLatestClient(req, currentModel, ReadRequiredFields) + if peErr != nil { + return *peErr, nil + } + + ctx := context.Background() + + streamWorkspace, resp, err := conn.StreamsApi.GetStreamWorkspace(ctx, *currentModel.ProjectId, *currentModel.WorkspaceName).Execute() + if err != nil { + if util.StatusNotFound(resp) { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "StreamWorkspace not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + }, nil + } + return HandleError(resp, constants.READ, err) + } + + model := GetStreamWorkspaceModel(streamWorkspace, currentModel) + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.ReadComplete, + ResourceModel: model, + }, nil +} + +func Update(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + conn, peErr := InitEnvWithLatestClient(req, currentModel, UpdateRequiredFields) + if peErr != nil { + return *peErr, nil + } + + ctx := context.Background() + + streamWorkspaceUpdateReq := NewStreamWorkspaceUpdateReq(currentModel) + updatedStreamWorkspace, resp, err := conn.StreamsApi.UpdateStreamWorkspace(ctx, *currentModel.ProjectId, *currentModel.WorkspaceName, streamWorkspaceUpdateReq).Execute() + if err != nil { + if util.StatusNotFound(resp) { + return handler.ProgressEvent{ + OperationStatus: handler.Failed, + Message: "StreamWorkspace not found", + HandlerErrorCode: string(types.HandlerErrorCodeNotFound), + }, nil + } + return HandleError(resp, constants.UPDATE, err) + } + + model := GetStreamWorkspaceModel(updatedStreamWorkspace, currentModel) + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: "Update Completed", + ResourceModel: model, + }, nil +} + +func Delete(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + conn, peErr := InitEnvWithLatestClient(req, currentModel, DeleteRequiredFields) + if peErr != nil { + return *peErr, nil + } + + ctx := context.Background() + + resp, err := conn.StreamsApi.DeleteStreamWorkspace(ctx, *currentModel.ProjectId, *currentModel.WorkspaceName).Execute() + if err != nil { + return HandleError(resp, constants.DELETE, err) + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: "Delete Completed", + }, nil +} + +func List(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { + conn, peErr := InitEnvWithLatestClient(req, currentModel, ListRequiredFields) + if peErr != nil { + return *peErr, nil + } + + ctx := context.Background() + + accumulatedStreamWorkspaces, apiResp, err := getAllStreamWorkspaces(ctx, conn, *currentModel.ProjectId) + if err != nil { + return HandleError(apiResp, constants.LIST, err) + } + + response := make([]interface{}, 0) + for i := range accumulatedStreamWorkspaces { + model := GetStreamWorkspaceModel(&accumulatedStreamWorkspaces[i], nil) + model.ProjectId = currentModel.ProjectId + model.Profile = currentModel.Profile + response = append(response, model) + } + + return handler.ProgressEvent{ + OperationStatus: handler.Success, + Message: constants.Complete, + ResourceModels: response, + }, nil +} + +func getAllStreamWorkspaces(ctx context.Context, conn *admin.APIClient, projectID string) ([]admin.StreamsTenant, *http.Response, error) { + pageNum := 1 + accumulatedStreamWorkspaces := make([]admin.StreamsTenant, 0) + + for allStreamWorkspacesRetrieved := false; !allStreamWorkspacesRetrieved; { + streamWorkspaces, resp, err := conn.StreamsApi.ListStreamWorkspacesWithParams(ctx, &admin.ListStreamWorkspacesApiParams{ + GroupId: projectID, + ItemsPerPage: util.Pointer(DefaultItemsPerPage), + PageNum: util.Pointer(pageNum), + }).Execute() + + if err != nil { + return nil, resp, err + } + accumulatedStreamWorkspaces = append(accumulatedStreamWorkspaces, streamWorkspaces.GetResults()...) + allStreamWorkspacesRetrieved = streamWorkspaces.GetTotalCount() <= len(accumulatedStreamWorkspaces) + pageNum++ + } + + return accumulatedStreamWorkspaces, nil, nil +} + +func HandleError(response *http.Response, method constants.CfnFunctions, err error) (handler.ProgressEvent, error) { + errMsg := fmt.Sprintf("%s error:%s", method, err.Error()) + return progress_events.GetFailedEventByResponse(errMsg, response), nil +} diff --git a/cfn-resources/stream-workspace/docs/README.md b/cfn-resources/stream-workspace/docs/README.md new file mode 100644 index 000000000..ee90cc770 --- /dev/null +++ b/cfn-resources/stream-workspace/docs/README.md @@ -0,0 +1,103 @@ +# MongoDB::Atlas::StreamWorkspace + +Returns, adds, updates, and removes Atlas Stream Processing Workspaces. The DataProcessRegion.Region property can be updated after creation. Other properties (WorkspaceName, ProjectId, Profile, StreamConfig, DataProcessRegion.CloudProvider) are create-only and require resource replacement to change. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+ "Type" : "MongoDB::Atlas::StreamWorkspace",
+ "Properties" : {
+ "Profile" : String,
+ "WorkspaceName" : String,
+ "DataProcessRegion" : StreamsDataProcessRegion,
+ "StreamConfig" : StreamConfig,
+ "ProjectId" : String,
+ }
+}
+
+
+### YAML
+
++Type: MongoDB::Atlas::StreamWorkspace +Properties: + Profile: String + WorkspaceName: String + DataProcessRegion: StreamsDataProcessRegion + StreamConfig: StreamConfig + ProjectId: String ++ +## Properties + +#### Profile + +The profile is defined in AWS Secret manager. See [Secret Manager Profile setup](../../../examples/profile-secret.yaml). + +_Required_: No + +_Type_: String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### WorkspaceName + +Human-readable label that identifies the stream workspace. + +_Required_: Yes + +_Type_: String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### DataProcessRegion + +Information about the cloud provider region in which MongoDB Cloud processes the stream. + +_Required_: Yes + +_Type_: StreamsDataProcessRegion + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### StreamConfig + +Configuration options for an Atlas Stream Processing Workspace. + +_Required_: No + +_Type_: StreamConfig + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### ProjectId + +Unique 24-hexadecimal character string that identifies the project. + +_Required_: Yes + +_Type_: String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +## Return Values + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### Id + +Unique 24-hexadecimal character string that identifies the stream workspace. + +#### Hostnames + +List that contains the hostnames assigned to the stream workspace. + diff --git a/cfn-resources/stream-workspace/docs/streamconfig.md b/cfn-resources/stream-workspace/docs/streamconfig.md new file mode 100644 index 000000000..600c9f6e9 --- /dev/null +++ b/cfn-resources/stream-workspace/docs/streamconfig.md @@ -0,0 +1,50 @@ +# MongoDB::Atlas::StreamWorkspace StreamConfig + +Configuration options for an Atlas Stream Processing Workspace. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+ "Tier" : String,
+ "MaxTierSize" : String
+}
+
+
+### YAML
+
++Tier: String +MaxTierSize: String ++ +## Properties + +#### Tier + +Selected tier for the Stream Workspace. Configures Memory / VCPU allowances. + +_Required_: Yes + +_Type_: String + +_Allowed Values_:
SP2 | SP5 | SP10 | SP30 | SP50
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### MaxTierSize
+
+Max tier size for the Stream Workspace. Configures Memory / VCPU allowances.
+
+_Required_: No
+
+_Type_: String
+
+_Allowed Values_: SP2 | SP5 | SP10 | SP30 | SP50
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
diff --git a/cfn-resources/stream-workspace/docs/streamsdataprocessregion.md b/cfn-resources/stream-workspace/docs/streamsdataprocessregion.md
new file mode 100644
index 000000000..ab6bf186a
--- /dev/null
+++ b/cfn-resources/stream-workspace/docs/streamsdataprocessregion.md
@@ -0,0 +1,48 @@
+# MongoDB::Atlas::StreamWorkspace StreamsDataProcessRegion
+
+Information about the cloud provider region in which MongoDB Cloud processes the stream.
+
+## Syntax
+
+To declare this entity in your AWS CloudFormation template, use the following syntax:
+
+### JSON
+
+
+{
+ "CloudProvider" : String,
+ "Region" : String
+}
+
+
+### YAML
+
++CloudProvider: String +Region: String ++ +## Properties + +#### CloudProvider + +Label that identifies the cloud service provider where MongoDB Cloud performs stream processing. For CloudFormation, this is restricted to AWS only. + +_Required_: Yes + +_Type_: String + +_Allowed Values_:
AWS
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### Region
+
+Name of the cloud provider region hosting Atlas Stream Processing.
+
+_Required_: Yes
+
+_Type_: String
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
diff --git a/cfn-resources/stream-workspace/mongodb-atlas-streamworkspace.json b/cfn-resources/stream-workspace/mongodb-atlas-streamworkspace.json
new file mode 100644
index 000000000..6e58a8eb5
--- /dev/null
+++ b/cfn-resources/stream-workspace/mongodb-atlas-streamworkspace.json
@@ -0,0 +1,117 @@
+{
+ "typeName": "MongoDB::Atlas::StreamWorkspace",
+ "description": "Returns, adds, updates, and removes Atlas Stream Processing Workspaces. The DataProcessRegion.Region property can be updated after creation. Other properties (WorkspaceName, ProjectId, Profile, StreamConfig, DataProcessRegion.CloudProvider) are create-only and require resource replacement to change.",
+ "sourceUrl": "https://github.com/mongodb/mongodbatlas-cloudformation-resources.git",
+ "definitions": {
+ "BaseStreamsRegion": {
+ "type": "string",
+ "description": "Name of the cloud provider region hosting Atlas Stream Processing."
+ },
+ "StreamsDataProcessRegion": {
+ "type": "object",
+ "description": "Information about the cloud provider region in which MongoDB Cloud processes the stream.",
+ "properties": {
+ "CloudProvider": {
+ "type": "string",
+ "description": "Label that identifies the cloud service provider where MongoDB Cloud performs stream processing. For CloudFormation, this is restricted to AWS only.",
+ "enum": ["AWS"],
+ "default": "AWS"
+ },
+ "Region": {
+ "$ref": "#/definitions/BaseStreamsRegion"
+ }
+ },
+ "required": ["CloudProvider", "Region"],
+ "additionalProperties": false
+ },
+ "StreamConfig": {
+ "type": "object",
+ "description": "Configuration options for an Atlas Stream Processing Workspace.",
+ "properties": {
+ "Tier": {
+ "type": "string",
+ "description": "Selected tier for the Stream Workspace. Configures Memory / VCPU allowances. Valid values: SP2, SP5, SP10, SP30, SP50.",
+ "title": "Stream Workspace Tier"
+ },
+ "MaxTierSize": {
+ "type": "string",
+ "description": "Max tier size for the Stream Workspace. Configures Memory / VCPU allowances. Valid values: SP2, SP5, SP10, SP30, SP50.",
+ "title": "Stream Workspace Max Tier Size"
+ }
+ },
+ "required": ["Tier"],
+ "additionalProperties": false
+ }
+ },
+ "properties": {
+ "Profile": {
+ "type": "string",
+ "description": "The profile is defined in AWS Secret manager. See [Secret Manager Profile setup](../../../examples/profile-secret.yaml).",
+ "default": "default"
+ },
+ "WorkspaceName": {
+ "description": "Human-readable label that identifies the stream workspace.",
+ "type": "string"
+ },
+ "DataProcessRegion": {
+ "$ref": "#/definitions/StreamsDataProcessRegion"
+ },
+ "StreamConfig": {
+ "$ref": "#/definitions/StreamConfig"
+ },
+ "Id": {
+ "description": "Unique 24-hexadecimal character string that identifies the stream workspace.",
+ "type": "string"
+ },
+ "ProjectId": {
+ "description": "Unique 24-hexadecimal character string that identifies the project.",
+ "type": "string"
+ },
+ "Hostnames": {
+ "description": "List that contains the hostnames assigned to the stream workspace.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "insertionOrder": false
+ }
+ },
+ "additionalProperties": false,
+ "required": ["ProjectId", "WorkspaceName", "DataProcessRegion"],
+ "readOnlyProperties": [
+ "/properties/Id",
+ "/properties/Hostnames"
+ ],
+ "primaryIdentifier": [
+ "/properties/WorkspaceName",
+ "/properties/ProjectId",
+ "/properties/Profile"
+ ],
+ "createOnlyProperties": [
+ "/properties/WorkspaceName",
+ "/properties/ProjectId",
+ "/properties/Profile",
+ "/properties/StreamConfig"
+ ],
+ "handlers": {
+ "create": {
+ "permissions": ["secretsmanager:GetSecretValue"]
+ },
+ "read": {
+ "permissions": ["secretsmanager:GetSecretValue"]
+ },
+ "update": {
+ "permissions": ["secretsmanager:GetSecretValue"]
+ },
+ "delete": {
+ "permissions": ["secretsmanager:GetSecretValue"]
+ },
+ "list": {
+ "permissions": ["secretsmanager:GetSecretValue"]
+ }
+ },
+ "documentationUrl": "https://github.com/mongodb/mongodbatlas-cloudformation-resources/blob/master/cfn-resources/stream-workspace/README.md",
+ "tagging": {
+ "taggable": false
+ }
+}
diff --git a/cfn-resources/stream-workspace/resource-role.yaml b/cfn-resources/stream-workspace/resource-role.yaml
new file mode 100644
index 000000000..d2b8e433c
--- /dev/null
+++ b/cfn-resources/stream-workspace/resource-role.yaml
@@ -0,0 +1,38 @@
+AWSTemplateFormatVersion: "2010-09-09"
+Description: >
+ This CloudFormation template creates a role assumed by CloudFormation
+ during CRUDL operations to mutate resources on behalf of the customer.
+
+Resources:
+ ExecutionRole:
+ Type: AWS::IAM::Role
+ Properties:
+ MaxSessionDuration: 8400
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: Allow
+ Principal:
+ Service: resources.cloudformation.amazonaws.com
+ Action: sts:AssumeRole
+ Condition:
+ StringEquals:
+ aws:SourceAccount:
+ Ref: AWS::AccountId
+ StringLike:
+ aws:SourceArn:
+ Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/MongoDB-Atlas-StreamWorkspace/*
+ Path: "/"
+ Policies:
+ - PolicyName: ResourceTypePolicy
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: Allow
+ Action:
+ - "secretsmanager:GetSecretValue"
+ Resource: "*"
+Outputs:
+ ExecutionRoleArn:
+ Value:
+ Fn::GetAtt: ExecutionRole.Arn
diff --git a/cfn-resources/stream-workspace/template.yml b/cfn-resources/stream-workspace/template.yml
new file mode 100644
index 000000000..4aa052513
--- /dev/null
+++ b/cfn-resources/stream-workspace/template.yml
@@ -0,0 +1,28 @@
+AWSTemplateFormatVersion: "2010-09-09"
+Transform: AWS::Serverless-2016-10-31
+Description: AWS SAM template for the MongoDB::Atlas::StreamWorkspace resource type
+
+Globals:
+ Function:
+ Timeout: 180 # docker start-up times can be long for SAM CLI
+ MemorySize: 256
+
+Resources:
+ TypeFunction:
+ Type: AWS::Serverless::Function
+ Properties:
+ Handler: bootstrap
+ Runtime: provided.al2
+ CodeUri: bin/
+
+ TestEntrypoint:
+ Type: AWS::Serverless::Function
+ Properties:
+ Handler: bootstrap
+ Runtime: provided.al2
+ CodeUri: bin/
+ Environment:
+ Variables:
+ MODE: Test
+ LOG_LEVEL: debug
+ MONGODB_ATLAS_BASE_URL: https://cloud-dev.mongodb.com/
diff --git a/cfn-resources/stream-workspace/test/README.md b/cfn-resources/stream-workspace/test/README.md
new file mode 100644
index 000000000..4b7e20856
--- /dev/null
+++ b/cfn-resources/stream-workspace/test/README.md
@@ -0,0 +1,40 @@
+# Stream Workspace
+
+## Prerequisites
+### Resources needed to run the manual QA
+- Atlas organization
+- Atlas project
+
+
+All resources are created as part of `cfn-testing-helper.sh`
+
+## Manual QA
+Please, follows the steps in [TESTING.md](../../../TESTING.md).
+
+
+### Success criteria when testing the resource
+- The Stream workspace should be visible in the stream processing page
+
+
+
+## Important Links
+- [API Documentation](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/group/endpoint-streams)
+- [Resource Usage Documentation](https://www.mongodb.com/docs/atlas/stream-processing/)
+
+## Contract Testing
+
+
+### Build Handler
+```bash
+make build
+```
+### Run the handler in a docker container
+```bash
+# Required the docker daemon running
+sam local start-lambda --skip-pull-image
+```
+
+### Run contract tests
+```bash
+cfn test --function-name TestEntrypoint --verbose
+```
diff --git a/cfn-resources/stream-workspace/test/cfn-test-create-inputs.sh b/cfn-resources/stream-workspace/test/cfn-test-create-inputs.sh
new file mode 100755
index 000000000..41c0ab6c1
--- /dev/null
+++ b/cfn-resources/stream-workspace/test/cfn-test-create-inputs.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# cfn-test-create-inputs.sh
+#
+# This tool generates json files in the inputs/ for `cfn test`.
+#
+
+set -euo pipefail
+
+rm -rf inputs
+mkdir inputs
+
+projectName="${1:-${PROJECT_NAME:-test-stream-workspace}}"
+
+#set profile
+profile="default"
+if [ ${MONGODB_ATLAS_PROFILE+x} ]; then
+ echo "profile set to ${MONGODB_ATLAS_PROFILE}"
+ profile=${MONGODB_ATLAS_PROFILE}
+fi
+
+# Try to find existing project first, or use provided project ID
+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
+ projectId=$(atlas projects list --output json | jq --arg NAME "${projectName}" -r '.results[] | select(.name==$NAME) | .id')
+ if [ -z "$projectId" ]; then
+ # Try to create project, but if IP is not on access list, use default project ID
+ if projectId=$(atlas projects create "${projectName}" --output=json 2>/dev/null | jq -r '.id'); then
+ echo -e "Created project \"${projectName}\" with id: ${projectId}\n"
+ else
+ # Fallback to project ID from environment variable
+ if [ -z "${MONGODB_ATLAS_PROJECT_ID:-}" ]; then
+ echo -e "ERROR: Could not create project and MONGODB_ATLAS_PROJECT_ID is not set. Please set MONGODB_ATLAS_PROJECT_ID environment variable.\n"
+ exit 1
+ fi
+ projectId="${MONGODB_ATLAS_PROJECT_ID}"
+ echo -e "Could not create project (IP may not be on access list). Using project ID from environment: ${projectId}\n"
+ fi
+ else
+ echo -e "FOUND project \"${projectName}\" with id: ${projectId}\n"
+ fi
+fi
+
+streamWorkspaceName="stream-workspace-$(date +%s)-$RANDOM"
+cloudProvider="AWS"
+region="VIRGINIA_USA"
+tier="SP30"
+
+WORDTOREMOVE="template."
+
+cd "$(dirname "$0")" || exit
+for inputFile in inputs_*; do
+ outputFile=${inputFile//$WORDTOREMOVE/}
+ jq --arg project_id "$projectId" \
+ --arg stream_workspace_name "$streamWorkspaceName" \
+ --arg cloud_provider "$cloudProvider" \
+ --arg region "$region" \
+ --arg profile "$profile" \
+ --arg tier "$tier" \
+ '.Profile?|=$profile | .ProjectId?|=$project_id | .WorkspaceName?|=$stream_workspace_name | .DataProcessRegion.CloudProvider?|=$cloud_provider | .DataProcessRegion.Region?|=$region | .StreamConfig.Tier?|=$tier' \
+ "$inputFile" >"../inputs/$outputFile"
+done
+
+cd ..
+
+ls -l inputs
diff --git a/cfn-resources/stream-workspace/test/cfn-test-delete-inputs.sh b/cfn-resources/stream-workspace/test/cfn-test-delete-inputs.sh
new file mode 100755
index 000000000..a19f0f89b
--- /dev/null
+++ b/cfn-resources/stream-workspace/test/cfn-test-delete-inputs.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# cfn-test-delete-inputs.sh
+#
+# This tool deletes the mongodb resources used for `cfn test` as inputs.
+
+set -euo pipefail
+
+function usage {
+ echo "usage:$0 "
+}
+
+projectId=$(jq -r '.ProjectId' ./inputs/inputs_1_create.json)
+workspaceName=$(jq -r '.WorkspaceName' ./inputs/inputs_1_create.json)
+
+# delete stream workspace (using instances delete for backward compatibility)
+if atlas streams instances delete "${workspaceName}" --projectId "${projectId}" --force; then
+ echo "deleting stream workspace with name ${workspaceName}"
+else
+ echo "failed to delete the stream workspace with name ${workspaceName}"
+fi
diff --git a/cfn-resources/stream-workspace/test/contract-testing/cfn-test-create-inputs.sh b/cfn-resources/stream-workspace/test/contract-testing/cfn-test-create-inputs.sh
new file mode 100755
index 000000000..d3b3d081c
--- /dev/null
+++ b/cfn-resources/stream-workspace/test/contract-testing/cfn-test-create-inputs.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+# Run this script with the Makefile
+# make create-test-resources
+#
+# This tool generates json files in the inputs/ for `cfn test`.
+#
+set -o errexit
+set -o nounset
+set -o pipefail
+set -x
+
+if [ -z "${AWS_DEFAULT_REGION+x}" ]; then
+ echo "AWS_DEFAULT_REGION must be set"
+ exit 1
+fi
+
+# setting projectName
+projectName="stream-workspace-$(date +%s)-$RANDOM"
+
+./test/cfn-test-create-inputs.sh "$projectName"
diff --git a/cfn-resources/stream-workspace/test/inputs_1_create.template.json b/cfn-resources/stream-workspace/test/inputs_1_create.template.json
new file mode 100644
index 000000000..40beb6ac4
--- /dev/null
+++ b/cfn-resources/stream-workspace/test/inputs_1_create.template.json
@@ -0,0 +1,13 @@
+{
+ "WorkspaceName": "",
+ "ProjectId": "",
+ "DataProcessRegion": {
+ "CloudProvider": "AWS",
+ "Region": "VIRGINIA_USA"
+ },
+ "StreamConfig": {
+ "Tier": "SP30",
+ "MaxTierSize": "SP50"
+ },
+ "Profile": "default"
+}
diff --git a/cfn-resources/stream-workspace/test/inputs_1_update.template.json b/cfn-resources/stream-workspace/test/inputs_1_update.template.json
new file mode 100644
index 000000000..9325f53a7
--- /dev/null
+++ b/cfn-resources/stream-workspace/test/inputs_1_update.template.json
@@ -0,0 +1,13 @@
+{
+ "WorkspaceName": "",
+ "ProjectId": "",
+ "DataProcessRegion": {
+ "CloudProvider": "AWS",
+ "Region": "DUBLIN_IRL"
+ },
+ "StreamConfig": {
+ "Tier": "SP30",
+ "MaxTierSize": "SP50"
+ },
+ "Profile": "default"
+}
diff --git a/cfn-resources/stream-workspace/test/stream-workspace.sample-cfn-request.json b/cfn-resources/stream-workspace/test/stream-workspace.sample-cfn-request.json
new file mode 100644
index 000000000..9e269ec6e
--- /dev/null
+++ b/cfn-resources/stream-workspace/test/stream-workspace.sample-cfn-request.json
@@ -0,0 +1,16 @@
+{
+ "desiredResourceState": {
+ "WorkspaceName": "stream-workspace",
+ "ProjectId": "",
+ "DataProcessRegion": {
+ "CloudProvider": "AWS",
+ "Region": "VIRGINIA_USA"
+ },
+ "StreamConfig": {
+ "Tier": "SP30",
+ "MaxTierSize": "SP50"
+ },
+ "Profile": "default"
+ },
+ "previousResourceState": {}
+}
diff --git a/cfn-resources/util/http_status.go b/cfn-resources/util/http_status.go
new file mode 100644
index 000000000..588d8eb1a
--- /dev/null
+++ b/cfn-resources/util/http_status.go
@@ -0,0 +1,33 @@
+// Copyright 2026 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 util
+
+import "net/http"
+
+func StatusNotFound(resp *http.Response) bool {
+ return resp != nil && resp.StatusCode == http.StatusNotFound
+}
+
+func StatusConflict(resp *http.Response) bool {
+ return resp != nil && resp.StatusCode == http.StatusConflict
+}
+
+func StatusBadRequest(resp *http.Response) bool {
+ return resp != nil && resp.StatusCode == http.StatusBadRequest
+}
+
+func StatusServiceUnavailable(resp *http.Response) bool {
+ return resp != nil && resp.StatusCode == http.StatusServiceUnavailable
+}
diff --git a/examples/atlas-streams/stream-workspace/README.md b/examples/atlas-streams/stream-workspace/README.md
new file mode 100644
index 000000000..dd59690e7
--- /dev/null
+++ b/examples/atlas-streams/stream-workspace/README.md
@@ -0,0 +1,22 @@
+# How to create a MongoDB::Atlas::StreamWorkspace
+
+## Step 1: Activate the stream workspace resource in cloudformation
+ Step a: Create Role using [execution-role.yaml](https://github.com/mongodb/mongodbatlas-cloudformation-resources/blob/master/examples/execution-role.yaml) in CFN resources folder.
+
+ Step b: Search for Mongodb::Atlas::StreamWorkspace resource.
+
+ (CloudFormation > Public extensions > choose 'Third party' > Search with " Execution name prefix = MongoDB " )
+ Step c: Select and activate
+ Enter the RoleArn that is created in step 1.
+
+ Your StreamWorkspace Resource is ready to use.
+
+## Step 2: Create template using [stream-workspace.json](stream-workspace.json)
+ Note: Make sure you are providing appropriate values for:
+ 1. ProjectId
+ 2. WorkspaceName (optional)
+ 3. CloudProvider: AWS (optional, default: AWS)
+ 4. Region (optional, default: VIRGINIA_USA)
+ 5. Tier: SP2, SP5, SP10, SP30, or SP50 (optional, default: SP30)
+ 6. MaxTierSize (optional, default: SP50)
+ 7. Profile (optional)
diff --git a/examples/atlas-streams/stream-workspace/stream-workspace.json b/examples/atlas-streams/stream-workspace/stream-workspace.json
new file mode 100644
index 000000000..dce36d9ff
--- /dev/null
+++ b/examples/atlas-streams/stream-workspace/stream-workspace.json
@@ -0,0 +1,151 @@
+{
+ "AWSTemplateFormatVersion": "2010-09-09",
+ "Description": "MongoDB Atlas AWS CloudFormation Quickstart for MongoDB::Atlas::StreamWorkspace",
+ "Parameters": {
+ "ProjectId": {
+ "Description": "Your MongoDB Cloud Project ID",
+ "Type": "String"
+ },
+ "WorkspaceName": {
+ "Description": "Name for the Stream Workspace",
+ "Type": "String",
+ "Default": ""
+ },
+ "CloudProvider": {
+ "Description": "Cloud provider for data processing region (AWS only for CloudFormation)",
+ "Type": "String",
+ "Default": "AWS",
+ "AllowedValues": ["AWS"]
+ },
+ "Region": {
+ "Description": "Region for data processing",
+ "Type": "String",
+ "Default": "VIRGINIA_USA"
+ },
+ "Tier": {
+ "Description": "Stream Workspace Tier (SP2, SP5, SP10, SP30, SP50)",
+ "Type": "String",
+ "Default": "SP30",
+ "AllowedValues": ["SP2", "SP5", "SP10", "SP30", "SP50"]
+ },
+ "MaxTierSize": {
+ "Description": "Max tier size for the Stream Workspace (optional)",
+ "Type": "String",
+ "Default": "SP50",
+ "AllowedValues": ["SP2", "SP5", "SP10", "SP30", "SP50"]
+ },
+ "Profile": {
+ "Description": "AWS Secrets Manager profile name",
+ "Type": "String",
+ "Default": "default"
+ }
+ },
+ "Conditions": {
+ "HasWorkspaceName": {
+ "Fn::Not": [
+ {
+ "Fn::Equals": [
+ {
+ "Ref": "WorkspaceName"
+ },
+ ""
+ ]
+ }
+ ]
+ }
+ },
+ "Resources": {
+ "StreamWorkspace": {
+ "Type": "MongoDB::Atlas::StreamWorkspace",
+ "Properties": {
+ "WorkspaceName": {
+ "Fn::If": [
+ "HasWorkspaceName",
+ {
+ "Ref": "WorkspaceName"
+ },
+ {
+ "Fn::Sub": "stream-workspace-${AWS::StackName}"
+ }
+ ]
+ },
+ "ProjectId": {
+ "Ref": "ProjectId"
+ },
+ "DataProcessRegion": {
+ "CloudProvider": {
+ "Ref": "CloudProvider"
+ },
+ "Region": {
+ "Ref": "Region"
+ }
+ },
+ "StreamConfig": {
+ "Tier": {
+ "Ref": "Tier"
+ },
+ "MaxTierSize": {
+ "Ref": "MaxTierSize"
+ }
+ },
+ "Profile": {
+ "Ref": "Profile"
+ }
+ }
+ }
+ },
+ "Outputs": {
+ "StreamWorkspaceId": {
+ "Description": "The unique identifier for the Stream Workspace",
+ "Value": {
+ "Fn::GetAtt": [
+ "StreamWorkspace",
+ "Id"
+ ]
+ },
+ "Export": {
+ "Name": {
+ "Fn::Sub": "${AWS::StackName}-StreamWorkspaceId"
+ }
+ }
+ },
+ "StreamWorkspaceName": {
+ "Description": "The name of the Stream Workspace",
+ "Value": {
+ "Fn::If": [
+ "HasWorkspaceName",
+ {
+ "Ref": "WorkspaceName"
+ },
+ {
+ "Fn::Sub": "stream-workspace-${AWS::StackName}"
+ }
+ ]
+ },
+ "Export": {
+ "Name": {
+ "Fn::Sub": "${AWS::StackName}-StreamWorkspaceName"
+ }
+ }
+ },
+ "StreamWorkspaceHostnames": {
+ "Description": "List of hostnames assigned to the stream workspace",
+ "Value": {
+ "Fn::Join": [
+ ",",
+ {
+ "Fn::GetAtt": [
+ "StreamWorkspace",
+ "Hostnames"
+ ]
+ }
+ ]
+ },
+ "Export": {
+ "Name": {
+ "Fn::Sub": "${AWS::StackName}-StreamWorkspaceHostnames"
+ }
+ }
+ }
+ }
+}