├── lib ├── saas-management │ ├── cell-provisioning-system │ │ ├── src │ │ │ ├── lambdas │ │ │ │ ├── PrepDeploy │ │ │ │ │ ├── requirements.txt │ │ │ │ │ └── lambda-prepare-deploy.py │ │ │ │ ├── Iterator │ │ │ │ │ └── iterator.py │ │ │ │ ├── persistCellDetails │ │ │ │ │ └── persistCellDetails.py │ │ │ │ └── persistTenantDetails │ │ │ │ │ └── persistTenantDetails.py │ │ │ ├── utils │ │ │ │ └── cdk-nag-utils.ts │ │ │ └── stepfunctions │ │ │ │ └── deploymentstatemachine.asl.json │ │ └── README.md │ ├── cell-management-system │ │ ├── src │ │ │ ├── lambdas │ │ │ │ ├── Authorizer │ │ │ │ │ └── requirements.txt │ │ │ │ ├── CapacityObserver │ │ │ │ │ ├── requirements.txt │ │ │ │ │ └── capacityObserver.py │ │ │ │ ├── ListCells │ │ │ │ │ └── listCells.py │ │ │ │ ├── DeactivateTenant │ │ │ │ │ └── deactivateTenant.py │ │ │ │ ├── ListTenants │ │ │ │ │ └── listTenants.py │ │ │ │ ├── UpdateCell │ │ │ │ │ └── updateCell.py │ │ │ │ ├── DescribeTenant │ │ │ │ │ └── describeTenant.py │ │ │ │ ├── PersistCellMetadata │ │ │ │ │ └── persistCellMetadata.py │ │ │ │ ├── CreateCell │ │ │ │ │ └── createCell.py │ │ │ │ ├── DescribeCell │ │ │ │ │ └── describeCell.py │ │ │ │ ├── PersistTenantMetadata │ │ │ │ │ └── persistTenantMetadata.py │ │ │ │ ├── ActivateTenant │ │ │ │ │ └── activateTenant.py │ │ │ │ └── AssignTenantToCell │ │ │ │ │ └── assignTenantToCell.py │ │ │ └── utils │ │ │ │ └── cdk-nag-utils.ts │ │ ├── README.md │ │ ├── identity-details.ts │ │ └── identity-provider-construct.ts │ ├── bridge │ │ ├── README.md │ │ ├── src │ │ │ └── utils │ │ │ │ └── cdk-nag-utils.ts │ │ └── bridge-stack.ts │ └── src │ │ └── lambda-function-construct.ts └── application-plane │ ├── cell-app-plane │ ├── cdk │ │ ├── lambdas │ │ │ ├── requirements.txt │ │ │ ├── tenant-de-provisioning.sql │ │ │ ├── tenant-provisioning.sql │ │ │ └── rds.py │ │ ├── interfaces │ │ │ └── identity-details.ts │ │ ├── test │ │ │ └── cdk.test.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ ├── lib │ │ │ ├── LambdaFunctionConstruct.ts │ │ │ ├── IdentityProviderConstruct.ts │ │ │ └── TenantRdsInitializerConstruct.ts │ │ ├── bin │ │ │ └── app.ts │ │ ├── utils │ │ │ └── cdk-nag-utils.ts │ │ └── cdk.json │ ├── src │ │ ├── resources │ │ │ ├── requirements.txt │ │ │ └── dockerfile │ │ ├── test.txt │ │ ├── models │ │ │ └── product_models.py │ │ ├── test │ │ │ └── test_product.py │ │ └── product.py │ ├── README.md │ └── scripts │ │ ├── update-tenant.sh │ │ ├── build-product-image.sh │ │ ├── deploy-cell.sh │ │ └── deploy-tenant.sh │ └── common-components │ ├── cell-router │ ├── README.md │ ├── src │ │ ├── utils │ │ │ └── cdk-nag-utils.ts │ │ └── functions │ │ │ └── cell-router.js │ └── common-cell-router-stack.ts │ └── observability │ ├── src │ └── utils │ │ └── cdk-nag-utils.ts │ └── observability-stack.ts ├── CODE_OF_CONDUCT.md ├── .gitleaksignore ├── scripts ├── deploy.sh ├── ws_deployment │ ├── _workshop-conf.sh │ ├── _manage-workshop-stack.sh │ ├── manage-workshop-stack.sh │ └── _workshop-shared-functions.sh ├── package-app-plane.sh ├── revert-failure-cloudwatch-api.sh ├── test_product.sh ├── test_activatetenant.sh ├── test_createcell.sh ├── test_createtenant.sh ├── load-generator.sh └── inject-failure-cloudwatch-api.sh ├── install.sh ├── package.json ├── tsconfig.json ├── LICENSE ├── README.md ├── CONTRIBUTING.md ├── cdk.json ├── bin └── saas-cell-based-architecture.ts ├── .gitignore └── Solution └── lab04 └── deploymentstatemachine.asl.json /lib/saas-management/cell-provisioning-system/src/lambdas/PrepDeploy/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | simplejson -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/Authorizer/requirements.txt: -------------------------------------------------------------------------------- 1 | python-jose[cryptography] 2 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/lambdas/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg[binary,pool] 2 | python-jose[cryptography] -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/CapacityObserver/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-embedded-metrics==3.2.0 2 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/lambdas/tenant-de-provisioning.sql: -------------------------------------------------------------------------------- 1 | DROP DATABASE tenant_id; 2 | DROP USER tenant_id; -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/src/resources/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==3.0.3 2 | boto3 3 | psycopg[binary,pool] 4 | python-jose[cryptography] -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/src/test.txt: -------------------------------------------------------------------------------- 1 | 2 | This is a test file needed to successfully run the unit test of Product Media Service -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/README.md: -------------------------------------------------------------------------------- 1 | # Cellular Architectures for SaaS 2 | ## SAAS Control Plane 3 | This is where the documentation will go... -------------------------------------------------------------------------------- /lib/application-plane/common-components/cell-router/README.md: -------------------------------------------------------------------------------- 1 | # Cellular Architectures for SaaS 2 | ## Cell Router 3 | 4 | This is where the documentation will go... -------------------------------------------------------------------------------- /lib/saas-management/cell-provisioning-system/README.md: -------------------------------------------------------------------------------- 1 | # Cellular Architectures for SaaS 2 | ## App Plane Orchestrator 3 | This is where the documentation will go... -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/identity-details.ts: -------------------------------------------------------------------------------- 1 | export interface IdentityDetails { 2 | name: string; 3 | details: { 4 | [key: string]: any; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/interfaces/identity-details.ts: -------------------------------------------------------------------------------- 1 | export interface IdentityDetails { 2 | name: string; 3 | details: { 4 | [key: string]: any; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /lib/saas-management/bridge/README.md: -------------------------------------------------------------------------------- 1 | # Cellular Architectures for SaaS 2 | ## Bridge 3 | The bridge stack is for components that "bridge" the control plane and data plane. This includes: 4 | - Eventbridge 5 | - SSM Parameters -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /.gitleaksignore: -------------------------------------------------------------------------------- 1 | 4578b040c0333a90aedd616bf48f8ceff160e994:lib/application-plane/cell-app-plane/cdk/stack_outputs.json:generic-api-key:13 2 | bc41ea11c6267a5a9ee4569f05f35511dd71c0f9:lib/application-plane/cell-app-plane/cdk/stack_outputs.json:generic-api-key:11 3 | ea153a436e7239f9d5b1ac6f54306a7bc2225b07:app-plane/product-service/scripts/test_harness.sh:generic-api-key:19 4 | -------------------------------------------------------------------------------- /lib/application-plane/common-components/observability/src/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-S1', 9 | reason: "The S3 bucket is a log bucket and does not require logs enabled." 10 | }, 11 | ]); 12 | } 13 | } -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/src/models/product_models.py: -------------------------------------------------------------------------------- 1 | class Product: 2 | def __init__(self, productId, productName, productDescription, productPrice, tenantId): 3 | self.productId = productId 4 | self.productName = productName 5 | self.productDescription = productDescription 6 | self.productPrice = productPrice 7 | self.tenantId = tenantId 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #need the empty file for the build to pass. We recreate this file during package the step 4 | touch lib/application-plane/cell-app-plane.zip 5 | 6 | npm install 7 | npx cdk bootstrap 8 | cdk deploy Bridge --require-approval never 9 | source "${PWD}/scripts/package-app-plane.sh" 10 | cdk deploy CommonObservability CellRouter CellManagementSystem CellProvisioningSystem --require-approval never 11 | CODE_BUILD=$(aws codebuild start-build --project-name SaasProductServiceBuildProject) -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/lambdas/tenant-provisioning.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA app; 2 | CREATE TABLE app.products ( 3 | product_id INTEGER PRIMARY KEY, 4 | product_name TEXT NOT NULL, 5 | product_description text NOT NULL, 6 | product_price NUMERIC NOT NULL, 7 | tenant_id TEXT NOT NULL 8 | ); 9 | CREATE USER WITH PASSWORD ''; 10 | GRANT CONNECT ON DATABASE TO ; 11 | GRANT USAGE ON SCHEMA app TO ; 12 | GRANT ALL PRIVILEGES ON table app.products TO ; -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | EMAIL="user@awssaascellworkshop.com" 4 | 5 | 6 | # Deploy the solution 7 | ./scripts/deploy.sh 8 | 9 | # Create a new cell with free tier 10 | ./scripts/test_createcell.sh freetier S 1 11 | CELL_ID=$(cat cell_id.txt) 12 | echo "Cell ID: $CELL_ID" 13 | 14 | # Create a new tenant with in that cell 15 | ./scripts/test_createtenant.sh $CELL_ID firsttenant $EMAIL free 16 | TENANT_ID=$(cat tenant_id.txt) 17 | echo "Tenant ID: $TENANT_ID" 18 | 19 | # Activate the tenant 20 | # ./scripts/test_activatetenant.sh $CELL_ID $TENANT_ID -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Server from '../lib/server-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/server-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Server.ServerStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/README.md: -------------------------------------------------------------------------------- 1 | # Cellular Architectures for SaaS 2 | ## Cell Application Plane 3 | 4 | All the scripts are in the scripts folder 5 | 6 | 1. To deploy the cell (pass cell name and cell size as param) 7 | ``` 8 | ./deploy-cell.sh cell1 M 9 | ``` 10 | 2. Build the image for product service 11 | 12 | ``` 13 | ./build-product-image.sh 14 | ``` 15 | 16 | 3. To deploy a new tenant in the cell (pass cell id, tenant id, email address as param) 17 | 18 | ``` 19 | ./deploy-tenant.sh cell1 tenant1 xxxxxx@amazon.com 20 | ``` 21 | 22 | 4. To update a tenant in the cell (pass cell id, tenant id, email address as param) 23 | 24 | ``` 25 | ./update-tenant.sh cell1 tenant1 xxxxxx@amazon.com 26 | ``` 27 | -------------------------------------------------------------------------------- /lib/saas-management/cell-provisioning-system/src/lambdas/Iterator/iterator.py: -------------------------------------------------------------------------------- 1 | def lambda_handler(event, context): 2 | print(event) 3 | index = event["iterator"]["index"] 4 | step = event["iterator"]["step"] 5 | total_waves = int(event["iterator"]["total_waves"]) 6 | 7 | index = index + step 8 | 9 | iterator = {} 10 | iterator["index"] = index 11 | iterator["step"] = index 12 | iterator["total_waves"] = total_waves 13 | 14 | if index < total_waves: 15 | iterator["continue"] = True 16 | else: 17 | iterator["continue"] = False 18 | 19 | 20 | return({ 21 | "iterator": iterator, 22 | "stacks": event["stacks"], 23 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas-cell-based-architecture", 3 | "version": "1.2.1", 4 | "bin": { 5 | "control-plane": "bin/saas-cell-based-architecture.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "22.10.1", 16 | "aws-cdk": "2.1031.1", 17 | "jest": "^29.7.0", 18 | "ts-jest": "^29.1.5", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.5.3" 21 | }, 22 | "dependencies": { 23 | "@aws-cdk/aws-lambda-python-alpha": "2.222.0-alpha.0", 24 | "aws-cdk-lib": "2.222.0", 25 | "constructs": "^10.0.0", 26 | "cdk-nag": "2.36.18" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/src/resources/dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Python image as the base image 2 | FROM public.ecr.aws/docker/library/python:3.12-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy the requirements file first to leverage Docker cache 8 | COPY /resources/requirements.txt /app/resources/requirements.txt 9 | 10 | # Install any required Python packages 11 | RUN pip install --no-cache-dir --trusted-host pypi.python.org -r /app/resources/requirements.txt 12 | 13 | # Copy the rest of the application code to the container 14 | COPY . /app 15 | 16 | # Expose the port the app runs on (if applicable) 17 | EXPOSE 80 18 | 19 | # Set the entrypoint for the container 20 | CMD ["python", "/app/product.py"] -------------------------------------------------------------------------------- /scripts/ws_deployment/_workshop-conf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | ## Defines workshop configuration shared amongst scripts 6 | 7 | ## Variables 8 | REPO_URL="${REPO_URL:=https://github.com/aws-samples/aws-saas-cell-based-architecture}" 9 | REPO_BRANCH_NAME="${REPO_BRANCH_NAME:=main}" 10 | REPO_NAME="${REPO_NAME:=aws-saas-cell-based-architecture}" 11 | TARGET_USER="participant" 12 | HOME_FOLDER="Workshop" 13 | DELAY=15 # Used to sleep in functions. Tweak as desired. 14 | 15 | echo "Set environment variables for the install:" 16 | echo "REPO_URL: $REPO_URL, REPO_BRANCH_NAME: $REPO_BRANCH_NAME, REPO_NAME: $REPO_NAME, TARGET_USER: $TARGET_USER, HOME_FOLDER: $HOME_FOLDER, DELAY: $DELAY" -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cell-app-plane", 3 | "version": "0.2.1", 4 | "bin": { 5 | "cell-app-plane": "bin/app.ts" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "22.10.1", 16 | "aws-cdk": "2.1031.1", 17 | "jest": "^29.7.0", 18 | "ts-jest": "^29.1.5", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.5.3" 21 | }, 22 | "dependencies": { 23 | "@aws-cdk/aws-lambda-python-alpha": "2.222.0-alpha.0", 24 | "aws-cdk-lib": "2.222.0", 25 | "constructs": "^10.0.0", 26 | "cdk-nag": "2.36.18", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /lib/saas-management/bridge/src/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-COG4', 9 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 10 | }, 11 | { 12 | id: 'AwsSolutions-APIG2', 13 | reason: 'API Gateway request validation is unnecessary; custom logic in the integration handles validation and logging of request errors.' 14 | }, 15 | { 16 | id: 'AwsSolutions-APIG4', 17 | reason: 'Custom request authorizer is being used.' 18 | }, 19 | { 20 | id: 'AwsSolutions-L1', 21 | reason: 'Ignoring, for stability of workshop' 22 | } 23 | ]); 24 | } 25 | } -------------------------------------------------------------------------------- /scripts/ws_deployment/_manage-workshop-stack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | create_workshop() { 6 | 7 | get_vscodeserver_id 8 | 9 | echo "Waiting for " $VSSERVER_ID 10 | aws ec2 start-instances --instance-ids "$VSSERVER_ID" 11 | aws ec2 wait instance-status-ok --instance-ids "$VSSERVER_ID" 12 | echo $VSSERVER_ID "ready" 13 | 14 | run_ssm_command "export UV_USE_IO_URING=0 && npm install typescript" 15 | run_ssm_command "cd /$HOME_FOLDER ; git clone --single-branch --branch $REPO_BRANCH_NAME $REPO_URL || git pull origin $REPO_BRANCH_NAME" 16 | run_ssm_command "chown -R $TARGET_USER:$TARGET_USER /$HOME_FOLDER" 17 | run_ssm_command ". ~/.bashrc && cd /$HOME_FOLDER/$REPO_NAME && chmod +x install.sh && ./install.sh" 18 | run_ssm_command "chown -R $TARGET_USER:$TARGET_USER /$HOME_FOLDER" 19 | 20 | } 21 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/scripts/update-tenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CELL_ID=$1 4 | TENANT_ID=$2 5 | TENANT_EMAIL=$3 6 | 7 | # Ensure a cell ID, tenant ID and tenant Email are provided 8 | if [ $# -eq 3 ]; then 9 | echo "Deploying stack: $0 $CELL_ID $TENANT_ID $TENANT_EMAIL" 10 | else 11 | echo "Need all three params: $0 " 12 | exit 1 13 | fi 14 | 15 | cd ../cdk 16 | echo ${PWD} 17 | npx tsc 18 | npm install 19 | npm run build 20 | cdk synth 21 | 22 | # Run the CDK deploy command with the correct parameters 23 | npx cdk deploy "Cell-$CELL_ID-Tenant-$TENANT_ID" --app "npx ts-node bin/app.ts" \ 24 | -c cellId="$CELL_ID" \ 25 | -c tenantId="$TENANT_ID" \ 26 | -c tenantEmail="$TENANT_EMAIL" \ 27 | --no-staging \ 28 | --require-approval never \ 29 | --concurrency 10 \ 30 | --asset-parallelism true 31 | 32 | 33 | # chmod +x deploy-tenants.sh 34 | #./deploy-tenants.sh tenant11 35 | #./deploy-tenants.sh tenant21 -------------------------------------------------------------------------------- /scripts/ws_deployment/manage-workshop-stack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | ## Import workshop configuration 6 | # This contains the create_workshop() and delete_workshop() functions 7 | FUNCTIONS=( _workshop-conf.sh _manage-workshop-stack.sh _workshop-shared-functions.sh ) 8 | for FUNCTION in "${FUNCTIONS[@]}"; do 9 | if [ -f $FUNCTION ]; then 10 | source $FUNCTION 11 | else 12 | echo "ERROR: $FUNCTION not found" 13 | fi 14 | done 15 | 16 | ## Calls the create and delete operations 17 | manage_workshop_stack() { 18 | create_workshop 19 | } 20 | 21 | for i in {1..3}; do 22 | echo "iteration number: $i" 23 | if manage_workshop_stack; then 24 | echo "successfully completed execution" 25 | exit 0 26 | else 27 | sleep "$((15*i))" 28 | fi 29 | done 30 | 31 | echo "failed to complete execution" 32 | exit 1 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/scripts/build-product-image.sh: -------------------------------------------------------------------------------- 1 | IMAGE_VERSION=$CODEBUILD_BUILD_NUMBER 2 | echo "image version: $IMAGE_VERSION" 3 | 4 | AWS_REGION=$1 5 | ACCOUNT_ID=$2 6 | 7 | # Building the code and preparing Product Docker Image 8 | cd ../src 9 | 10 | docker build --platform linux/amd64 -f resources/dockerfile -t product-service:$IMAGE_VERSION . 11 | echo "build completed" 12 | 13 | # Login to ECR 14 | aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com 15 | echo "logged in" 16 | 17 | # Tag the image and push it to product-service ECR repo 18 | docker tag product-service:$IMAGE_VERSION $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/product-service:$IMAGE_VERSION 19 | echo "tagged" 20 | 21 | docker push $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/product-service:$IMAGE_VERSION 22 | echo "pushed" 23 | 24 | # Update the current image version 25 | aws ssm put-parameter --name "/saas/image-version" --type "String" --value $IMAGE_VERSION --overwrite -------------------------------------------------------------------------------- /lib/saas-management/cell-provisioning-system/src/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-IAM4', 9 | reason: 'AWS Managed Policies are ok for this solution' 10 | }, 11 | { 12 | id: 'AwsSolutions-IAM5', 13 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix for AWS Managed policies.' 14 | }, 15 | { 16 | id: 'AwsSolutions-L1', 17 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 18 | }, 19 | { 20 | id: 'AwsSolutions-SQS3', 21 | reason: 'Payload is not critical and does not require a DLQ' 22 | }, 23 | { 24 | id: 'AwsSolutions-SF1', 25 | reason: 'ERROR level is sufficient for this solution' 26 | }, 27 | { 28 | id: 'AwsSolutions-ECR1', 29 | reason: 'Public access to ECR is required for this solution' 30 | }, 31 | { 32 | id: 'AwsSolutions-L1', 33 | reason: 'Ignoring, for stability of workshop' 34 | } 35 | ]); 36 | } 37 | } -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/lib/LambdaFunctionConstruct.ts: -------------------------------------------------------------------------------- 1 | import { RemovalPolicy } from 'aws-cdk-lib'; 2 | import { Function, Runtime, Code } from 'aws-cdk-lib/aws-lambda'; 3 | import { Construct } from 'constructs'; 4 | import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs"; 5 | import * as lambda_python from '@aws-cdk/aws-lambda-python-alpha'; 6 | 7 | export interface LambdaFunctionProps { 8 | friendlyFunctionName: string 9 | index: string 10 | entry: string 11 | handler: string 12 | environmentVariables?: {[key: string]: string} 13 | } 14 | 15 | export class LambdaFunction extends Construct { 16 | public readonly lambdaFunction: Function; 17 | 18 | constructor(scope: Construct, id: string, props: LambdaFunctionProps) { 19 | super(scope, id); 20 | 21 | const logGroup = new LogGroup(this, 'LogGroup', { 22 | logGroupName: `/aws/lambda/${props.friendlyFunctionName}`, 23 | retention: RetentionDays.ONE_WEEK, 24 | removalPolicy: RemovalPolicy.DESTROY 25 | }); 26 | 27 | this.lambdaFunction = new lambda_python.PythonFunction(this, 'lambdaFunction', { 28 | entry: props.entry, 29 | runtime: Runtime.PYTHON_3_13, 30 | handler: props.handler, 31 | index: props.index, 32 | logGroup: logGroup, 33 | environment: { 34 | ...props.environmentVariables 35 | } 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/application-plane/common-components/cell-router/src/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-COG4', 9 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 10 | }, 11 | { 12 | id: 'AwsSolutions-APIG2', 13 | reason: 'API Gateway request validation is unnecessary; custom logic in the integration handles validation and logging of request errors.' 14 | }, 15 | { 16 | id: 'AwsSolutions-APIG4', 17 | reason: 'Custom request authorizer is being used.' 18 | }, 19 | { 20 | id: 'AwsSolutions-CFR1', 21 | reason: 'CloudFront geo restrictions are not necessary for this solution.' 22 | }, 23 | { 24 | id: 'AwsSolutions-CFR2', 25 | reason: 'WAF is unnecessary. Defense in depth is present in the solution.' 26 | }, 27 | { 28 | id: 'AwsSolutions-CFR4', 29 | reason: "Suppress false positive. Minimum TLS version is set" 30 | }, 31 | { 32 | id: 'AwsSolutions-CFR7', 33 | reason: "We are leveraging origin access identities" 34 | }, 35 | { 36 | id: 'AwsSolutions-IAM4', 37 | reason: 'AWS managed policies acceptable for this solution.' 38 | } 39 | ]); 40 | } 41 | } -------------------------------------------------------------------------------- /scripts/package-app-plane.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Color definitions 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | YELLOW='\033[1;33m' 7 | NC='\033[0m' # No Color 8 | 9 | CURRENT_DIR=${PWD} 10 | APP_PLANE_ARCHIVE_DIR="$CURRENT_DIR/lib/application-plane" 11 | APP_PLANE_ARCHIVE_FILENAME="cell-app-plane.zip" 12 | 13 | if [ -f "$APP_PLANE_ARCHIVE_DIR/$APP_PLANE_ARCHIVE_FILENAME" ]; then 14 | echo -e "${YELLOW}Removing existing${NC} $APP_PLANE_ARCHIVE_FILENAME" 15 | rm "$APP_PLANE_ARCHIVE_DIR/$APP_PLANE_ARCHIVE_FILENAME" 16 | fi 17 | 18 | echo -e "${YELLOW}Creating a .zip package of the python code${NC}" 19 | cd $APP_PLANE_ARCHIVE_DIR/cell-app-plane 20 | zip -r ../$APP_PLANE_ARCHIVE_FILENAME . -x ".git/*" -x "**/node_modules/*" -x "**/cdk.out/*" 21 | 22 | cd $CURRENT_DIR 23 | BUCKET_NAME=$(aws cloudformation describe-stacks --stack-name Bridge --query "Stacks[0].Outputs[?OutputKey=='S3SourceBucketName'].OutputValue" | jq -r '.[0]') 24 | ZIP_FILE_NAME=$(aws s3api list-objects --bucket $BUCKET_NAME | jq -r '.Contents[0].Key') 25 | echo -e "${YELLOW}Current Directory:${NC} ${PWD}" 26 | echo -e "${YELLOW}Uploading${NC} $APP_PLANE_ARCHIVE_DIR/$APP_PLANE_ARCHIVE_FILENAME to $BUCKET_NAME" 27 | aws s3 cp $APP_PLANE_ARCHIVE_DIR/$APP_PLANE_ARCHIVE_FILENAME s3://$BUCKET_NAME/$ZIP_FILE_NAME 28 | echo -e "${GREEN}Upload complete, go to the AWS CodePipeline console to track progress:${NC} https://us-east-1.console.aws.amazon.com/codesuite/codepipeline/pipelines/CellDeploymentStateMachine/view?region=us-east-1" 29 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/bin/app.ts: -------------------------------------------------------------------------------- 1 | import { Aspects, App } from 'aws-cdk-lib'; 2 | import { CellStack } from '../lib/CellStack'; 3 | import { CellTenantStack } from '../lib/CellTenantStack'; 4 | import { AwsSolutionsChecks } from 'cdk-nag' 5 | 6 | const app = new App(); 7 | 8 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 9 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 10 | 11 | // Read environment variables 12 | const awsAccountId = process.env.AWS_ACCOUNT_ID; 13 | const awsRegion = process.env.AWS_REGION; 14 | 15 | const env = { account: awsAccountId, region: awsRegion }; 16 | 17 | const cellId = app.node.tryGetContext('cellId'); 18 | const cellSize = app.node.tryGetContext('cellSize'); 19 | 20 | // Create a new cell 21 | new CellStack(app, `Cell-${cellId}`, { cellId, cellSize }); 22 | 23 | // Read tenantId, email and priorityBase from context 24 | const tenantId = app.node.tryGetContext('tenantId'); 25 | const tenantEmail = app.node.tryGetContext('tenantEmail'); 26 | const priorityBase = app.node.tryGetContext('tenantListenerPriorityBase'); 27 | const productImageVersion = app.node.tryGetContext('productImageVersion'); 28 | 29 | // Check if tenantId is provided in context and instantiate TenantStack if it is 30 | if (tenantId) { 31 | const stackName = `Cell-${cellId}-Tenant-${tenantId}`; 32 | new CellTenantStack(app, stackName, { cellId, cellSize, tenantId, tenantEmail, priorityBase, productImageVersion, env }); 33 | } 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/scripts/deploy-cell.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | if [ -z "$2" ]; then 9 | echo "Usage: $0 " 10 | exit 1 11 | fi 12 | 13 | CELL_NAME=$1 14 | CELL_SIZE=$2 15 | echo "Deploying Cell $CELL_NAME with Size $CELL_SIZE" 16 | 17 | cd ../cdk 18 | echo ${PWD} 19 | echo Compiling TypeScript 20 | npx tsc 21 | echo Installing npm packages 22 | npm install 23 | 24 | echo Executing npm run build 25 | npm run build 26 | 27 | echo Executing cdk synth and bootstrap 28 | cdk synth 29 | npx cdk bootstrap 30 | 31 | echo Starting the deployment of CDK Stack 32 | 33 | # Executing the CellStack CDK stack to create ECS Cluster, ALB, S3 Bucket, ECR, Parameter Store, APIGW Resource 34 | npx cdk deploy "Cell-$CELL_NAME" --app "npx ts-node bin/app.ts" \ 35 | --context cellId="$CELL_NAME" \ 36 | --context cellSize="$CELL_SIZE" \ 37 | --require-approval never \ 38 | --concurrency 10 \ 39 | --asset-parallelism true \ 40 | --outputs-file stack_outputs.json 41 | 42 | if [ -s stack_outputs.json ]; then 43 | filesize=$(stat -c%s stack_outputs.json) 44 | if [ $filesize -gt 5 ]; then 45 | echo CDK deploy ran successfully and wrote stack outputs to file, exiting cleanly 46 | echo "CONTENTS OF stack_outputs.json:" 47 | cat stack_outputs.json 48 | ls -lh 49 | else 50 | echo CDK deploy ended without outputs being written to file, exiting with an error code 51 | echo "CONTENTS OF stack_outputs.json:" 52 | cat stack_outputs.json 53 | ls -lh 54 | exit 1 55 | fi 56 | else 57 | echo CDK deploy ended without outputs file being created at all, so exiting with an error code 58 | exit 1 59 | fi -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-COG2', 9 | reason: 'MFA cannot be leveraged for a workshop. User and account is shortlived, and MFA is unnecessary/impractical.' 10 | }, 11 | { 12 | id: 'AwsSolutions-COG3', 13 | reason: 'Advanced Security Mode is unnecessary for a workshop. User and account is shortlived.' 14 | }, 15 | { 16 | id: 'AwsSolutions-COG4', 17 | reason: 'Authorization cannot be used for OPTIONS as part of CORS' 18 | }, 19 | { 20 | id: 'AwsSolutions-IAM4', 21 | reason: 'AWS Managed policies are permitted.' 22 | }, 23 | { 24 | id: 'AwsSolutions-IAM5', 25 | reason: 'Restriction applied at the log group level, and more granular restrictions are not possible as resources are created dynamically as and when required.' 26 | }, 27 | { 28 | id: 'AwsSolutions-APIG2', 29 | reason: 'API Gateway request validation is unnecessary; custom logic in the integration handles validation and logging of request errors.' 30 | }, 31 | { 32 | id: 'AwsSolutions-APIG3', 33 | reason: 'WAFv2 is not required.' 34 | }, 35 | { 36 | id: 'AwsSolutions-APIG4', 37 | reason: 'Custom request authorizer is being used.' 38 | }, 39 | { 40 | id: 'AwsSolutions-COG1', 41 | reason: 'Default password is sufficient for a workshop with temporary users' 42 | }, 43 | { 44 | id: 'AwsSolutions-L1', 45 | reason: 'Ignoring, for stability of workshop' 46 | } 47 | ]); 48 | } 49 | } -------------------------------------------------------------------------------- /scripts/revert-failure-cloudwatch-api.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Color definitions 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | YELLOW='\033[1;33m' 7 | NC='\033[0m' # No Color 8 | 9 | # Parse command line arguments 10 | if [ -z "$2" ]; then 11 | echo -e "Usage: $0 --cell-id " 12 | exit 1 13 | fi 14 | 15 | CELL_ID="$2" 16 | 17 | # Check if required parameters are provided 18 | if [ -z "$CELL_ID" ] ; then 19 | echo -e "${RED}Missing required parameters${NC}" 20 | echo -e "${YELLOW}Usage: $0 --cell-id ${NC}" 21 | exit 1 22 | fi 23 | 24 | # Retrieve VPC ID that starts with the cell-id prefix 25 | vpc_id=$(aws ec2 describe-vpcs --filters "Name=tag:Name,Values=Cell-${CELL_ID}*" --query "Vpcs[0].VpcId" --output text) 26 | 27 | if [ -z "$vpc_id" ] || [ "$vpc_id" == "None" ]; then 28 | echo -e "${RED}No VPC found with cell-id prefix '${CELL_ID}'${NC}" 29 | exit 1 30 | fi 31 | 32 | echo -e "${YELLOW}Found VPC: $vpc_id${NC}" 33 | 34 | # Retrieve the default NACL for the VPC 35 | nacl_id=$(aws ec2 describe-network-acls --filters "Name=vpc-id,Values=${vpc_id}" "Name=default,Values=true" --query "NetworkAcls[0].NetworkAclId" --output text) 36 | 37 | if [ -z "$nacl_id" ] || [ "$nacl_id" == "None" ]; then 38 | echo -e "${RED}No default NACL found for VPC ${vpc_id}${NC}" 39 | exit 1 40 | fi 41 | 42 | echo -e "${YELLOW}Found NACL: $nacl_id${NC}" 43 | 44 | 45 | # Function to delete a deny rule 46 | delete_deny_rule() { 47 | local rule_number=$1 48 | 49 | aws ec2 delete-network-acl-entry \ 50 | --network-acl-id $nacl_id \ 51 | --rule-number $rule_number\ 52 | --egress 53 | 54 | echo -e "${GREEN}Deleted deny rules for rule number $rule_number${NC}" 55 | } 56 | 57 | 58 | # Delete deny rules 59 | delete_deny_rule 90 60 | delete_deny_rule 91 61 | 62 | echo -e "${GREEN}NACL rules rollback complete.${NC}" 63 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/identity-provider-construct.ts: -------------------------------------------------------------------------------- 1 | import { aws_cognito, StackProps, RemovalPolicy, Duration } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { IdentityDetails } from './identity-details'; 4 | 5 | export class IdentityProvider extends Construct { 6 | public readonly identityDetails: IdentityDetails; 7 | 8 | constructor(scope: Construct, id: string, props?: StackProps) { 9 | super(scope, id); 10 | 11 | const systemAdminUserPool = new aws_cognito.UserPool(this, 'SystemAdminUserPool', { 12 | autoVerify: { email: true }, 13 | accountRecovery: aws_cognito.AccountRecovery.EMAIL_ONLY, 14 | removalPolicy: RemovalPolicy.DESTROY, 15 | standardAttributes: { 16 | email: { 17 | required: true, 18 | mutable: true, 19 | }, 20 | }, 21 | }); 22 | 23 | const writeAttributes = new aws_cognito.ClientAttributes() 24 | .withStandardAttributes({ email: true }) 25 | 26 | const systemAdminUserPoolClient = new aws_cognito.UserPoolClient(this, 'SystemAdminUserPoolClient', { 27 | userPool: systemAdminUserPool, 28 | generateSecret: false, 29 | authFlows: { 30 | userPassword: true, 31 | adminUserPassword: true, 32 | userSrp: true, 33 | custom: false, 34 | }, 35 | idTokenValidity: Duration.hours(2), 36 | writeAttributes: writeAttributes, 37 | oAuth: { 38 | scopes: [ 39 | aws_cognito.OAuthScope.EMAIL, 40 | aws_cognito.OAuthScope.OPENID, 41 | aws_cognito.OAuthScope.PROFILE, 42 | ], 43 | flows: { 44 | authorizationCodeGrant: true, 45 | implicitCodeGrant: true, 46 | }, 47 | }, 48 | }); 49 | 50 | this.identityDetails = { 51 | name: 'Cognito', 52 | details: { 53 | userPoolId: systemAdminUserPool.userPoolId, 54 | appClientId: systemAdminUserPoolClient.userPoolClientId, 55 | }, 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /scripts/ws_deployment/_workshop-shared-functions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | # Run an SSM command on an EC2 instance 6 | run_ssm_command() { 7 | SSM_COMMAND="$1" 8 | parameters=$(jq -n --arg cm "runuser -l \"$TARGET_USER\" -c \"$SSM_COMMAND\"" '{executionTimeout:["3600"], commands: [$cm]}') 9 | comment=$(echo "$SSM_COMMAND" | cut -c1-100) 10 | # send ssm command to instance id in VSSERVER_ID 11 | sh_command_id=$(aws ssm send-command \ 12 | --targets "Key=InstanceIds,Values=$VSSERVER_ID" \ 13 | --document-name "AWS-RunShellScript" \ 14 | --parameters "$parameters" \ 15 | --cloud-watch-output-config "CloudWatchOutputEnabled=true,CloudWatchLogGroupName=workshopsetuplog" \ 16 | --timeout-seconds 3600 \ 17 | --comment "$comment" \ 18 | --output text \ 19 | --query "Command.CommandId") 20 | 21 | command_status="InProgress" # seed status var 22 | while [[ "$command_status" == "InProgress" || "$command_status" == "Pending" || "$command_status" == "Delayed" ]]; do 23 | sleep $DELAY 24 | command_invocation=$(aws ssm get-command-invocation \ 25 | --command-id "$sh_command_id" \ 26 | --instance-id "$VSSERVER_ID") 27 | # echo -E "$command_invocation" | jq # for debugging purposes 28 | command_status=$(echo -E "$command_invocation" | jq -r '.Status') 29 | done 30 | 31 | if [ "$command_status" != "Success" ]; then 32 | echo "failed executing $SSM_COMMAND : $command_status" && exit 1 33 | else 34 | echo "successfully completed execution!" 35 | fi 36 | } 37 | 38 | # Get vscodeserver instance ID 39 | get_vscodeserver_id() { 40 | VSSERVER_ID=$(aws ec2 describe-instances \ 41 | --filter "Name=tag:Name,Values=VSCodeServer" \ 42 | --query 'Reservations[].Instances[].{Instance:InstanceId}' \ 43 | --output text) 44 | 45 | echo "vscodeserver instance id: $VSSERVER_ID" 46 | } 47 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/scripts/deploy-tenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ $# -eq 6 ]; then 4 | echo "Deploying stack: $0 $CELL_ID $CELL_SIZE $TENANT_ID $TENANT_EMAIL $TENANT_LISTENER_PRIORITY $PRODUCT_IMAGE_VERSION" 5 | else 6 | echo "Need all six params: $0 " 7 | exit 1 8 | fi 9 | 10 | CELL_ID=$1 11 | CELL_SIZE=$2 12 | TENANT_ID=$3 13 | TENANT_EMAIL=$4 14 | TENANT_LISTENER_PRIORITY=$5 15 | PRODUCT_IMAGE_VERSION=$6 16 | 17 | cd ../cdk 18 | echo ${PWD} 19 | echo Compiling TypeScript 20 | npx tsc 21 | echo Installing npm packages 22 | npm install 23 | 24 | echo Executing npm run build 25 | npm run build 26 | 27 | echo Executing cdk synth 28 | cdk synth 29 | 30 | echo Starting the deployment of CDK Stack 31 | 32 | # Run the CDK deploy command with the correct parameters 33 | npx cdk deploy "Cell-$CELL_ID-Tenant-$TENANT_ID" --app "npx ts-node bin/app.ts" \ 34 | --context cellId="$CELL_ID" \ 35 | --context cellSize="$CELL_SIZE" \ 36 | --context tenantId="$TENANT_ID" \ 37 | --context tenantEmail="$TENANT_EMAIL" \ 38 | --context tenantListenerPriorityBase="$TENANT_LISTENER_PRIORITY" \ 39 | --context productImageVersion="$PRODUCT_IMAGE_VERSION" \ 40 | --no-staging \ 41 | --require-approval never \ 42 | --concurrency 10 \ 43 | --asset-parallelism true \ 44 | --outputs-file tenant_stack_outputs.json 45 | 46 | if [ -s tenant_stack_outputs.json ]; then 47 | filesize=$(stat -c%s tenant_stack_outputs.json) 48 | if [ $filesize -gt 5 ]; then 49 | echo CDK deploy ran successfully and wrote stack outputs to file, exiting cleanly 50 | echo "CONTENTS OF tenant_stack_outputs.json:" 51 | cat tenant_stack_outputs.json 52 | ls -lh 53 | else 54 | echo CDK deploy ended without outputs being written to file, exiting with an error code 55 | echo "CONTENTS OF tenant_stack_outputs.json:" 56 | cat tenant_stack_outputs.json 57 | ls -lh 58 | exit 1 59 | fi 60 | else 61 | echo CDK deploy ended without outputs file being created at all, so exiting with an error code 62 | exit 1 63 | fi 64 | 65 | cd ../scripts -------------------------------------------------------------------------------- /lib/saas-management/src/lambda-function-construct.ts: -------------------------------------------------------------------------------- 1 | import { RemovalPolicy } from 'aws-cdk-lib'; 2 | import { Function, Runtime } from 'aws-cdk-lib/aws-lambda'; 3 | import { Role, Policy, ServicePrincipal, PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam'; 4 | import { Construct } from 'constructs'; 5 | import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs"; 6 | import * as lambda_python from '@aws-cdk/aws-lambda-python-alpha'; 7 | 8 | export interface LambdaFunctionProps { 9 | friendlyFunctionName: string 10 | index: string 11 | entry: string 12 | handler: string 13 | environmentVariables?: {[key: string]: string} 14 | } 15 | 16 | export class LambdaFunction extends Construct { 17 | public readonly lambdaFunction: Function; 18 | 19 | constructor(scope: Construct, id: string, props: LambdaFunctionProps) { 20 | super(scope, id); 21 | 22 | const logGroup = new LogGroup(this, 'LogGroup', { 23 | logGroupName: `/aws/lambda/${props.friendlyFunctionName}`, 24 | retention: RetentionDays.ONE_WEEK, 25 | removalPolicy: RemovalPolicy.DESTROY 26 | }); 27 | 28 | const executionRole = new Role(this, 'LambdaExecutionRole', { 29 | assumedBy: new ServicePrincipal('lambda.amazonaws.com'), 30 | }); 31 | 32 | const logPolicy = new Policy(this, 'LambdaLoggingPolicy', { 33 | statements: [ 34 | new PolicyStatement({ 35 | effect: Effect.ALLOW, 36 | actions: ['logs:CreateLogGroup'], 37 | resources: [logGroup.logGroupArn], 38 | }), 39 | new PolicyStatement({ 40 | effect: Effect.ALLOW, 41 | actions: ['logs:CreateLogStream','logs:PutLogEvents'], 42 | resources: [`${logGroup.logGroupArn}/*`], 43 | }), 44 | ], 45 | }); 46 | logPolicy.attachToRole(executionRole); 47 | 48 | this.lambdaFunction = new lambda_python.PythonFunction(this, 'lambdaFunction', { 49 | entry: props.entry, 50 | runtime: Runtime.PYTHON_3_13, 51 | handler: props.handler, 52 | index: props.index, 53 | logGroup: logGroup, 54 | environment: { 55 | ...props.environmentVariables 56 | }, 57 | role: executionRole 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/ListCells/listCells.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | import json 4 | import os 5 | import boto3 6 | import logging 7 | 8 | dynamodb = boto3.resource('dynamodb') 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | def handler(event, context): 14 | 15 | # Log the entire event 16 | logger.info('Received event: %s', event) 17 | 18 | # Extract the request body 19 | try: 20 | return list_cells() 21 | except Exception as e: 22 | logger.error("Couldn't scan for cells: %s",e) 23 | return { 24 | 'statusCode': 500, 25 | 'body': json.dumps({'error': 'A Problem occured listing cells'}) 26 | } 27 | 28 | def list_cells(): 29 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 30 | table = dynamodb.Table(cell_management_table) 31 | cells = [] 32 | scan_kwargs = { 33 | "ProjectionExpression": "PK, cell_name, current_status, cell_utilization" 34 | } 35 | try: 36 | done = False 37 | start_key = None 38 | while not done: 39 | if start_key: 40 | scan_kwargs["ExclusiveStartKey"] = start_key 41 | response = table.scan(**scan_kwargs) 42 | dynamo_response = response.get("Items",[]) 43 | # Iterate over a copy of the list to avoid semgrep list-modify-while-iterate rule. 44 | for item in dynamo_response[:]: 45 | if "#" in item['PK']: 46 | dynamo_response.remove(item) 47 | else: 48 | item['CellId'] = item.pop('PK') 49 | item['CellName'] = item.pop('cell_name') 50 | item['Status'] = item.pop('current_status') 51 | item['CellUtilization'] = str(item.pop('cell_utilization')) 52 | cells.extend(response.get("Items", [])) 53 | start_key = response.get("LastEvaluatedKey", None) 54 | done = start_key is None 55 | except Exception as e: 56 | logger.error("Couldn't scan for cells: %s",e) 57 | raise 58 | 59 | # Process the request and generate a response 60 | response = { 61 | 'statusCode': 200, 62 | 'body': json.dumps(cells) 63 | } 64 | return response -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/DeactivateTenant/deactivateTenant.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import boto3 4 | import logging 5 | from botocore.exceptions import ClientError 6 | from botocore.config import Config 7 | 8 | logger = logging.getLogger() 9 | logger.setLevel(logging.INFO) 10 | 11 | CELL_ROUTER_KVS_ARN = os.environ.get('CELL_ROUTER_KVS_ARN') 12 | 13 | kvsClient = boto3.session.Session().client( 14 | 'cloudfront-keyvaluestore', 15 | region_name='us-east-1', 16 | config=Config(tcp_keepalive=True,retries={'mode': 'adaptive'},signature_version='v4') 17 | ) 18 | 19 | def delete_cell_routing_entry(tenantId): 20 | try: 21 | logger.debug('removing config for tenant from KVS') 22 | 23 | # Get the current etag 24 | describe_response = kvsClient.describe_key_value_store( 25 | KvsARN=CELL_ROUTER_KVS_ARN 26 | ) 27 | 28 | # Remove the routing information from KVS 29 | delete_response = kvsClient.delete_key( 30 | KvsARN=CELL_ROUTER_KVS_ARN, 31 | Key=tenantId, 32 | IfMatch=describe_response.get('ETag') 33 | ) 34 | 35 | except ClientError as e: 36 | logger.error(f'Error persisting config object: {e}') 37 | except json.JSONDecodeError as e: 38 | logger.error(f'Error parsing config object: {e}') 39 | except Exception as e: 40 | logger.error(f'Unexpected Error persisting or parsing config object: {e}') 41 | 42 | def handler(event, context): 43 | 44 | # Log the entire event 45 | logger.info('Received event: %s', event) 46 | 47 | # Extract the request body 48 | body = event.get('body') 49 | if body: 50 | try: 51 | data = json.loads(body) 52 | tenant_id = data.get('TenantId') 53 | delete_cell_routing_entry(tenant_id) 54 | logger.info('routing successfully removed for %s', tenant_id) 55 | except json.JSONDecodeError: 56 | logger.error('Invalid JSON in request body') 57 | return { 58 | 'statusCode': 400, 59 | 'body': json.dumps({'error': 'Invalid JSON in request body'}) 60 | } 61 | else: 62 | logger.info('No request body') 63 | 64 | # Process the request and generate a response 65 | response = { 66 | 'statusCode': 200, 67 | 'body': json.dumps({'TenantId': tenant_id, 'Status': "INACTIVE"}) 68 | } 69 | 70 | # Log the response 71 | logger.info('Response: %s', response) 72 | return response -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/lib/IdentityProviderConstruct.ts: -------------------------------------------------------------------------------- 1 | import { aws_cognito, StackProps, RemovalPolicy, Duration } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { IdentityDetails } from '../interfaces/identity-details' 4 | 5 | export class IdentityProvider extends Construct { 6 | public readonly identityDetails: IdentityDetails; 7 | 8 | constructor(scope: Construct, id: string, props?: StackProps) { 9 | super(scope, id); 10 | 11 | const tenantUserPool = new aws_cognito.UserPool(this, 'TenantUserPool', { 12 | selfSignUpEnabled: true, 13 | userVerification: { 14 | emailSubject: 'Verify your email', 15 | emailBody: 'Thanks for signing up! Your verification code is {####}', 16 | emailStyle: aws_cognito.VerificationEmailStyle.CODE, 17 | }, 18 | accountRecovery: aws_cognito.AccountRecovery.EMAIL_ONLY, 19 | removalPolicy: RemovalPolicy.DESTROY, 20 | standardAttributes: { 21 | email: { 22 | required: true, 23 | mutable: true, 24 | }, 25 | }, 26 | passwordPolicy: { 27 | minLength: 8, 28 | requireLowercase: true, 29 | requireUppercase: true, 30 | requireSymbols: true, 31 | requireDigits: true 32 | }, 33 | userPoolName: 'tenant-user-pool', 34 | customAttributes: { 35 | tenantId: new aws_cognito.StringAttribute({ mutable: true }), 36 | role: new aws_cognito.StringAttribute({ mutable: true }), 37 | }, 38 | }); 39 | 40 | const writeAttributes = new aws_cognito.ClientAttributes() 41 | .withStandardAttributes({ email: true }) 42 | 43 | const tenantUserPoolClient = new aws_cognito.UserPoolClient(this, 'TenantUserPoolClient', { 44 | userPool: tenantUserPool, 45 | generateSecret: false, 46 | authFlows: { 47 | userPassword: true, 48 | adminUserPassword: true, 49 | userSrp: true, 50 | custom: false, 51 | }, 52 | idTokenValidity: Duration.hours(2), 53 | writeAttributes: writeAttributes, 54 | oAuth: { 55 | scopes: [ 56 | aws_cognito.OAuthScope.EMAIL, 57 | aws_cognito.OAuthScope.OPENID, 58 | aws_cognito.OAuthScope.PROFILE, 59 | ], 60 | flows: { 61 | authorizationCodeGrant: true, 62 | implicitCodeGrant: true, 63 | }, 64 | }, 65 | }); 66 | 67 | this.identityDetails = { 68 | name: 'Cognito', 69 | details: { 70 | userPoolId: tenantUserPool.userPoolId, 71 | appClientId: tenantUserPoolClient.userPoolClientId, 72 | }, 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /scripts/test_product.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Ensure a cell ID, tenant ID and tenant Email are provided 4 | if [ $# -ge 2 ]; then 5 | echo "Usage: $0 " 6 | else 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | CELL_ID=$1 12 | TENANT_ID=$2 13 | 14 | CELL_STACK_NAME="Cell-$CELL_ID" 15 | 16 | CELL_ROUTER_STACK="CellRouter" 17 | 18 | USER_POOL_ID=$(aws cloudformation describe-stacks --stack-name $CELL_STACK_NAME --query "Stacks[0].Outputs[?ExportName=='CellUserPoolId-$CELL_ID'].OutputValue" | jq -r '.[0]') 19 | CLIENT_ID=$(aws cloudformation describe-stacks --stack-name $CELL_STACK_NAME --query "Stacks[0].Outputs[?ExportName=='CellAppClientId-$CELL_ID'].OutputValue" | jq -r '.[0]') 20 | 21 | USER="tenantadmin-$TENANT_ID" 22 | PASSWORD="#SaaSCellArchitecutre1234" 23 | 24 | echo "CLIENT_ID: ${CLIENT_ID}" 25 | echo "USER_POOL_ID: ${USER_POOL_ID}" 26 | 27 | # required in order to initiate-auth 28 | UPDATE_AUTH_FLOW=$(aws cognito-idp update-user-pool-client \ 29 | --user-pool-id "$USER_POOL_ID" \ 30 | --client-id "$CLIENT_ID" \ 31 | --explicit-auth-flows USER_PASSWORD_AUTH) 32 | 33 | # remove need for password reset 34 | UPDATE_PWD=$(aws cognito-idp admin-set-user-password \ 35 | --user-pool-id "$USER_POOL_ID" \ 36 | --username "$USER" \ 37 | --password "$PASSWORD" \ 38 | --permanent) 39 | 40 | # get credentials for user 41 | AUTHENTICATION_RESULT=$(aws cognito-idp initiate-auth \ 42 | --auth-flow USER_PASSWORD_AUTH \ 43 | --client-id "${CLIENT_ID}" \ 44 | --auth-parameters "USERNAME=${USER},PASSWORD='${PASSWORD}'" \ 45 | --query 'AuthenticationResult') 46 | 47 | ID_TOKEN=$(echo "$AUTHENTICATION_RESULT" | jq -r '.IdToken') 48 | echo "ID_TOKEN: ${ID_TOKEN}" 49 | 50 | ROUTER_ENDPOINT=$(aws cloudformation describe-stacks \ 51 | --stack-name "$CELL_ROUTER_STACK" \ 52 | --query "Stacks[0].Outputs[?contains(OutputKey,'DistributionUrl')].OutputValue" \ 53 | --output text \ 54 | --region us-east-1) 55 | echo "ROUTER_ENDPOINT: ${ROUTER_ENDPOINT}" 56 | 57 | PRODUCT_ID=$RANDOM 58 | echo $PRODUCT_ID 59 | 60 | DATA=$(jq --null-input \ 61 | --arg productId "$PRODUCT_ID" \ 62 | '{ 63 | "productId": $productId, 64 | "productName": "test product", 65 | "productDescription": "test product", 66 | "productPrice": 20 67 | }' 68 | ) 69 | 70 | curl --request POST \ 71 | --url "https://${ROUTER_ENDPOINT}/product/" \ 72 | --header "Authorization: Bearer ${ID_TOKEN}" \ 73 | --header 'content-type: application/json' \ 74 | --data "$DATA" 75 | 76 | curl "https://${ROUTER_ENDPOINT}/product/" \ 77 | --header "Authorization: Bearer ${ID_TOKEN}" \ 78 | --header 'content-type: application/json' 79 | 80 | echo "" # add newline 81 | 82 | 83 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/ListTenants/listTenants.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | import json 4 | import os 5 | import boto3 6 | import logging 7 | 8 | dynamodb = boto3.resource('dynamodb') 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | def handler(event, context): 14 | 15 | # Log the entire event 16 | logger.info('Received event: %s', event) 17 | 18 | # Extract the request body 19 | try: 20 | if 'queryStringParameters' in event and event['queryStringParameters'] is not None and 'CellId' in event['queryStringParameters']: 21 | cell_id = event['queryStringParameters']['CellId'] 22 | logger.info('CellId: %s', cell_id) 23 | if cell_id is not None: 24 | return list_tenants(cell_id) 25 | else: 26 | logger.error('Invalid request parameters') 27 | return { 28 | 'statusCode': 400, 29 | 'body': json.dumps({'error': 'Invalid JSON in request body'}) 30 | } 31 | except Exception as e: 32 | logger.error("Couldn't scan for tenants: %s",e) 33 | return { 34 | 'statusCode': 500, 35 | 'body': json.dumps({'error': 'A Problem occurred listing tenants'}) 36 | } 37 | 38 | def list_tenants(cell_id): 39 | tenant_management_table = os.environ.get('TENANT_MANAGEMENT_TABLE') 40 | table = dynamodb.Table(tenant_management_table) 41 | tenants = [] 42 | try: 43 | 44 | response = table.query( 45 | IndexName='TenantsByCellIdIndex', 46 | KeyConditionExpression='cell_id = :cell_id', 47 | ExpressionAttributeValues={ 48 | ':cell_id': cell_id 49 | }, 50 | ProjectionExpression='tenant_id, tenant_name, current_status' 51 | ) 52 | 53 | if 'Items' in response: 54 | for item in response['Items']: 55 | tenant_id = item.get('tenant_id') 56 | tenant_name = item.get('tenant_name') 57 | tenant_status = item.get('current_status') 58 | 59 | tenants.append({ 60 | 'TenantId': tenant_id, 61 | 'TenantName': tenant_name, 62 | 'Status': tenant_status 63 | }) 64 | 65 | return { 66 | 'statusCode': 200, 67 | 'body': json.dumps(tenants) 68 | } 69 | 70 | except Exception as e: 71 | logger.error("Couldn't scan for tenants: %s",e) 72 | raise 73 | 74 | # Process the request and generate a response 75 | response = { 76 | 'statusCode': 200, 77 | 'body': json.dumps(tenants) 78 | } 79 | return response -------------------------------------------------------------------------------- /lib/saas-management/bridge/bridge-stack.ts: -------------------------------------------------------------------------------- 1 | import { CfnOutput, Stack, StackProps, RemovalPolicy, Duration, PhysicalName } from 'aws-cdk-lib'; 2 | import { EventBus } from 'aws-cdk-lib/aws-events'; 3 | import { StringParameter } from 'aws-cdk-lib/aws-ssm'; 4 | import { CdkNagUtils } from './src/utils/cdk-nag-utils' 5 | import { Construct } from 'constructs'; 6 | import { Bucket, BucketAccessControl, BucketEncryption, BlockPublicAccess } from 'aws-cdk-lib/aws-s3'; 7 | 8 | export interface BridgeStackProps extends StackProps { 9 | 10 | } 11 | 12 | export class Bridge extends Stack { 13 | readonly s3LogBucketArn: string; 14 | readonly orchestrationEventBus: EventBus; 15 | readonly imageVersionParam: StringParameter; 16 | readonly cellSourceBucketArn: string; 17 | 18 | constructor(scope: Construct, id: string, props: BridgeStackProps) { 19 | super(scope, id, props); 20 | 21 | // Handle CDK nag suppressions. 22 | CdkNagUtils.suppressCDKNag(this); 23 | const logBucket = new Bucket(this, 's3AccessLogBucket', { 24 | bucketName: PhysicalName.GENERATE_IF_NEEDED, 25 | removalPolicy: RemovalPolicy.DESTROY, 26 | autoDeleteObjects: true, 27 | versioned: false, 28 | accessControl: BucketAccessControl.LOG_DELIVERY_WRITE, 29 | enforceSSL: true, 30 | encryption: BucketEncryption.S3_MANAGED, 31 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 32 | lifecycleRules: [{ 33 | id: 'object retention policy', 34 | expiration: Duration.days(7) 35 | }] 36 | }); 37 | 38 | this.s3LogBucketArn = logBucket.bucketArn; 39 | 40 | 41 | // Create the EventBridge bus for the communication between control plane and application plane 42 | const orchestrationEventBus = new EventBus(this, 'CellManagementBus', { 43 | eventBusName: 'cell-management-bus', 44 | }); 45 | 46 | const imageVersionParameter = new StringParameter(this, 'ImageVersionParameter', { 47 | parameterName: '/saas/image-version', 48 | stringValue: 'not populated', 49 | }); 50 | 51 | const s3CellSourceBucket = new Bucket(this, 'CellSourceBucket',{ 52 | removalPolicy: RemovalPolicy.DESTROY, 53 | autoDeleteObjects: true, 54 | enforceSSL: true, 55 | encryption: BucketEncryption.S3_MANAGED, 56 | versioned: true, 57 | serverAccessLogsBucket: logBucket, 58 | serverAccessLogsPrefix: "logs/buckets/cell-source-bucket/", 59 | }); 60 | 61 | this.orchestrationEventBus = orchestrationEventBus; 62 | this.imageVersionParam = imageVersionParameter; 63 | this.cellSourceBucketArn = s3CellSourceBucket.bucketArn; 64 | 65 | new CfnOutput(this, 'S3SourceBucketName', { 66 | value: s3CellSourceBucket.bucketName, 67 | description: 'S3 Source Bucket Name', 68 | exportName: 'S3SourceBucketName' 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Building resilient and scalable SaaS applications with a cell-based architecture 2 | This workshop delves into the realm of cell-based architecture, an approach that has gained traction in recent years for its ability to enhance the scalability, reliability, and efficiency of SaaS architectures. By deploying the SaaS application into smaller, independent cells, workshop participants will learn how to solve challenges like reducing contention between tenants, supporting a tiering strategy, reducing blast radius, and increasing the overall resilience of their systems. The workshop will provide a hands-on experience, allowing learners to go under the hood of a working SaaS application, and explore various aspects of a cell-based architecture, including provisioning, configuration, observability, routing, and sharding of cells. 3 | 4 | The AWS workshop can be found at the following location: 5 | 6 | [Building resilient and scalable SaaS applications with a cell-based architecture](https://catalog.us-east-1.prod.workshops.aws/workshops/44d6042c-1ff7-43c2-9343-cff46c43e165/en-US) 7 | 8 | Whilst intended to be used primarily for the workshop, the code can be used independently to explore and interact with the various components of a cell-based architecture: 9 | 10 | 1. To deploy the solution, use the included deployment script to package and deploy: 11 | 12 | `sh ./scripts/deploy.sh` 13 | 14 | 2. Run the below script to create a new cell. Here 'freetier' is the name of cell, 'S' is cellSize (allowed values are S,M,L) and '1' is the WaveNumber for the cell deployment. 15 | 16 | `sh ./scripts/test_createcell.sh freetier S 1` 17 | 18 | 3. Run the below script to create a new tenant within the cell. Here 'o2345v9' is the cell id (from previous step), 'tenant1' is tenant name and 'test@test.com' is the email for the tenant admin, that is provisioned in Cognito as part of this and 'free' denotes tenant tiers. 19 | 20 | `sh ./scripts/test_createtenant.sh o2345v9 tenant1 test@test.com free` 21 | 22 | 4. Run the below script to activate the tenant. Here 'o2345v9' is the cell id (from create cell step), 'mhmuy9t2p' is tenant id (from create tenant step). 23 | 24 | `sh ./scripts/test_activatetenant.sh o2345v9 mhmuy9t2p` 25 | 26 | 5. Run the below script to add and get products for the tenant. Pass the cell id and tenant id. The below will script will use the cell router to route to the correct cell endpoint. 27 | 28 | `sh ./scripts/test_product.sh o2345v9 mhmuy9t2p` 29 | 30 | 6. (Optional) If you make any changes to the docker image then run below command to re-upload the app plane source. This will trigger the deployment pipeline. 31 | 32 | `sh ./scripts/package-app-plane.sh` 33 | 34 | 35 | ## Security 36 | 37 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 38 | 39 | ## License 40 | 41 | This library is licensed under the MIT-0 License. See the LICENSE file. 42 | 43 | -------------------------------------------------------------------------------- /scripts/test_activatetenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | if [ -z "$2" ]; then 9 | echo "Usage: $1 " 10 | exit 1 11 | fi 12 | 13 | CELL_ID=$1 14 | TENANT_ID=$2 15 | 16 | 17 | CELL_MANAGEMENT_STACK_NAME="CellManagementSystem" 18 | 19 | USER_POOL_ID=$(aws cloudformation describe-stacks --stack-name $CELL_MANAGEMENT_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='UserPoolId'].OutputValue" | jq -r '.[0]') 20 | CLIENT_ID=$(aws cloudformation describe-stacks --stack-name $CELL_MANAGEMENT_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='UserPoolClientId'].OutputValue" | jq -r '.[0]') 21 | 22 | USER="admin" 23 | PASSWORD="#CellBased1234" 24 | 25 | echo "CLIENT_ID: ${CLIENT_ID}" 26 | echo "USER_POOL_ID: ${USER_POOL_ID}" 27 | 28 | # required in order to initiate-auth 29 | UPDATE_USER_POOL_AUTH=$(aws cognito-idp update-user-pool-client \ 30 | --user-pool-id "$USER_POOL_ID" \ 31 | --client-id "$CLIENT_ID" \ 32 | --explicit-auth-flows USER_PASSWORD_AUTH) 33 | 34 | # check if user exists 35 | DATA=$((aws cognito-idp list-users \ 36 | --user-pool-id "$USER_POOL_ID" \ 37 | --filter "username = \"$USER\"" \ 38 | --output json 39 | ) | jq -r '.Users[0].Username') 40 | echo "DATA: ${DATA}" 41 | 42 | if [ "$DATA" != "$USER" ]; then 43 | # create user 44 | CREATE_ADMIN=$(aws cognito-idp admin-create-user \ 45 | --user-pool-id "$USER_POOL_ID" \ 46 | --username "$USER" ) 47 | 48 | 49 | # remove need for password reset 50 | SET_PASSWORD=$(aws cognito-idp admin-set-user-password \ 51 | --user-pool-id "$USER_POOL_ID" \ 52 | --username "$USER" \ 53 | --password "$PASSWORD" \ 54 | --permanent) 55 | fi 56 | 57 | # get credentials for user 58 | AUTHENTICATION_RESULT=$(aws cognito-idp initiate-auth \ 59 | --auth-flow USER_PASSWORD_AUTH \ 60 | --client-id "${CLIENT_ID}" \ 61 | --auth-parameters "USERNAME=${USER},PASSWORD='${PASSWORD}'" \ 62 | --query 'AuthenticationResult') 63 | 64 | ID_TOKEN=$(echo "$AUTHENTICATION_RESULT" | jq -r '.IdToken') 65 | echo "ID_TOKEN: ${ID_TOKEN}" 66 | 67 | 68 | CELL_MANAGEMENT_API_ENDPOINT=$(aws cloudformation describe-stacks \ 69 | --stack-name "$CELL_MANAGEMENT_STACK_NAME" \ 70 | --query "Stacks[0].Outputs[?contains(OutputKey,'CellManagementApiEndpoint')].OutputValue" \ 71 | --output text) 72 | echo "CELL_MANAGEMENT_API_ENDPOINT: ${CELL_MANAGEMENT_API_ENDPOINT}" 73 | 74 | # echo "activating tenant..." 75 | 76 | TENANT_STATUS=$(curl --request PUT \ 77 | --url "${CELL_MANAGEMENT_API_ENDPOINT}ActivateTenant" \ 78 | --header "Authorization: Bearer ${ID_TOKEN}" \ 79 | --header 'content-type: application/json' \ 80 | --data "{\"CellId\":\"$CELL_ID\",\"TenantId\":\"$TENANT_ID\"}" \ 81 | | jq -r '.Status') 82 | 83 | echo "TENANT STATUS: ${TENANT_STATUS}" 84 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-VPC7', 9 | reason: 'VPC Flow logs unnecessary for current implementation, relying on access log from API Gateway.' 10 | }, 11 | { 12 | id: 'AwsSolutions-ECS4', 13 | reason: 'CloudWatch Container Insights not required for tracking microservice performance.' 14 | }, 15 | { 16 | id: 'AwsSolutions-COG3', 17 | reason: 'AdvancedSecurityMode: Advanced mode is deprecated.' 18 | }, 19 | { 20 | id: 'AwsSolutions-COG4', 21 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 22 | }, 23 | { 24 | id: 'AwsSolutions-IAM4', 25 | reason: 'AWS Managed policies are permitted.' 26 | }, 27 | { 28 | id: 'AwsSolutions-L1', 29 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 30 | }, 31 | { 32 | id: 'AwsSolutions-SNS2', 33 | reason: 'Not utilizing SNS notifications.' 34 | }, 35 | { 36 | id: 'AwsSolutions-SNS3', 37 | reason: 'Not utilizing SNS notifications.' 38 | }, 39 | { 40 | id: 'AwsSolutions-EC26', 41 | reason: 'EBS encryption unnecessary.' 42 | }, 43 | { 44 | id: 'AwsSolutions-IAM5', 45 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix for AWS Managed policies.' 46 | }, 47 | { 48 | id: 'AwsSolutions-AS3', 49 | reason: 'Notifications not required for Auto Scaling Group.' 50 | }, 51 | { 52 | id: 'AwsSolutions-ELB2', 53 | reason: 'ELB access logs not required.' 54 | }, 55 | { 56 | id: 'AwsSolutions-SMG4', 57 | reason: 'Automatic rotation not necessary.' 58 | }, 59 | { 60 | id: 'AwsSolutions-RDS6', 61 | reason: 'Fine-grained access control methods in place to restrict tenant access.' 62 | }, 63 | { 64 | id: 'AwsSolutions-RDS10', 65 | reason: 'Deletion protection not required for current use case.' 66 | }, 67 | { 68 | id: 'AwsSolutions-APIG2', 69 | reason: 'API Gateway request validation is unnecessary; custom logic in the integration handles validation and logging of request errors.' 70 | }, 71 | { 72 | id: 'AwsSolutions-APIG4', 73 | reason: 'Custom request authorizer is being used.' 74 | }, 75 | { 76 | id: 'AwsSolutions-EC23', 77 | reason: 'ALB is protected in private subnet to receive traffic only from the NLB in a private integration with API Gateway.' 78 | }, 79 | { 80 | id: 'AwsSolutions-ECS2', 81 | reason: 'For this solution, statically defined environment variables are sufficient.' 82 | } 83 | ]); 84 | } 85 | } -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/UpdateCell/updateCell.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | import json 4 | import os 5 | import boto3 6 | import logging 7 | 8 | # Initialize clients 9 | dynamodb = boto3.resource('dynamodb') 10 | 11 | logger = logging.getLogger() 12 | logger.setLevel(logging.INFO) 13 | 14 | def handler(event, context): 15 | 16 | # Log the entire event 17 | logger.info('Received event: %s', event) 18 | 19 | # Extract the request body 20 | body = event.get('body') 21 | if body: 22 | try: 23 | data = json.loads(body) 24 | if "WaveNumber" in data or "CellName" in data: 25 | logger.info('Request body: %s', data) 26 | 27 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 28 | ddb_table = dynamodb.Table(cell_management_table) 29 | 30 | cell_id = data.get('CellId') 31 | cell_name = data.get('CellName',None) 32 | cell_wave_number = data.get('WaveNumber',None) 33 | 34 | exp_attr_values = {} 35 | update_exp = "" 36 | 37 | if cell_name is not None: 38 | update_exp += "set cell_name = :cn" 39 | exp_attr_values[':cn'] = cell_name 40 | if cell_wave_number is not None: 41 | if update_exp: 42 | update_exp += ", wave_number = :wn" 43 | else: 44 | update_exp = "set wave_number = :wn" 45 | exp_attr_values[':wn'] = cell_wave_number 46 | 47 | dynamo_response = ddb_table.update_item( 48 | Key={ 49 | 'PK': cell_id 50 | }, 51 | UpdateExpression=update_exp, 52 | ExpressionAttributeValues=exp_attr_values, 53 | ReturnValues="UPDATED_NEW" 54 | ) 55 | 56 | # Process the request and generate a response 57 | response = { 58 | 'statusCode': 200, 59 | 'body': json.dumps({'CellId': cell_id, 'Status': 'updated'}) 60 | } 61 | else: 62 | logger.info('Invalid Request: WaveNumber or CellName not present') 63 | response = { 64 | 'statusCode': 400, 65 | 'body': json.dumps({'error': 'WaveNumber or CellName not present'}) 66 | } 67 | except json.JSONDecodeError: 68 | logger.error('Invalid JSON in request body') 69 | response = { 70 | 'statusCode': 400, 71 | 'body': json.dumps({'error': 'Invalid JSON in request body'}) 72 | } 73 | else: 74 | logger.info('Invalid Request: WaveNumber, CellName or both must be provided') 75 | response = { 76 | 'statusCode': 400, 77 | 'body': json.dumps({'error': 'WaveNumber, CellName or both must be provided'}) 78 | } 79 | # Log the response 80 | logger.info('Response: %s', response) 81 | return response -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/lib/TenantRdsInitializerConstruct.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as ec2 from 'aws-cdk-lib/aws-ec2' 3 | import * as lambda from 'aws-cdk-lib/aws-lambda' 4 | import { RemovalPolicy, Duration, CfnOutput } from 'aws-cdk-lib' 5 | import { Construct } from 'constructs' 6 | import { AwsCustomResource, AwsCustomResourcePolicy, AwsSdkCall, PhysicalResourceId } from 'aws-cdk-lib/custom-resources' 7 | import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs"; 8 | import * as lambda_python from '@aws-cdk/aws-lambda-python-alpha'; 9 | import path = require('path'); 10 | import * as iam from 'aws-cdk-lib/aws-iam'; 11 | import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets' 12 | 13 | 14 | export interface TenantRDSInitializerProps { 15 | cellId: string, 16 | vpc: ec2.IVpc, 17 | dbCredSecretName: string 18 | secretArn: string 19 | } 20 | 21 | export class TenantRDSInitializer extends Construct { 22 | public readonly response: string 23 | public readonly customResource: AwsCustomResource 24 | public readonly function: lambda.Function 25 | public readonly fnSg: ec2.SecurityGroup 26 | 27 | constructor (scope: Construct, id: string, props: TenantRDSInitializerProps) { 28 | super(scope, id) 29 | 30 | const fnSg = new ec2.SecurityGroup(this, 'TenantInitializerFnSg', { 31 | securityGroupName: `${id}TenantInitializerFnSg`, 32 | vpc: props.vpc, 33 | allowAllOutbound: true 34 | }) 35 | 36 | const logGroup = new LogGroup(this, 'LogGroup', { 37 | logGroupName: `/aws/lambda/TenantRDSInitializer${id}`, 38 | retention: RetentionDays.ONE_WEEK, 39 | removalPolicy: RemovalPolicy.DESTROY 40 | }); 41 | 42 | //a new lambda role that grants getscretvalue 43 | const lambdaRole = new iam.Role(this, 'LambdaRole', { 44 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 45 | }); 46 | lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')); 47 | lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole')); 48 | lambdaRole.addToPolicy(new iam.PolicyStatement({ 49 | actions: ['secretsmanager:GetSecretValue'], 50 | resources: ['*'], 51 | })); 52 | 53 | const lambdaFunction = new lambda_python.PythonFunction(this, `TenantRDSInitializer${id}`, { 54 | entry: path.join(__dirname, '../lambdas'), 55 | index: 'rds.py', 56 | runtime: lambda.Runtime.PYTHON_3_13, 57 | handler: "handler", 58 | logGroup: logGroup, 59 | vpc: props.vpc, 60 | vpcSubnets: props.vpc.selectSubnets( { 61 | subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS 62 | }), 63 | securityGroups: [fnSg], 64 | environment: { 65 | DB_CRED_SECRET_NAME: props.dbCredSecretName 66 | }, 67 | role: lambdaRole, 68 | timeout: Duration.seconds(60) 69 | }); 70 | 71 | this.function = lambdaFunction 72 | this.fnSg = fnSg 73 | 74 | new CfnOutput(this, `TenantRDSInitializerLambdaName-${props.cellId}`, {value: lambdaFunction.functionName, exportName: `TenantRDSInitializerLambdaName-${props.cellId}`}) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/lambdas/rds.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import os 3 | import psycopg 4 | 5 | secrets_manager = boto3.client('secretsmanager') 6 | 7 | def handler(event, context): 8 | try: 9 | creds_secret_name = os.getenv('DB_CRED_SECRET_NAME') 10 | tenant_state = event.get('tenantState') 11 | tenant_id = event.get('tenantId') 12 | tenant_secret_name = event.get('tenantSecretName') 13 | print(tenant_secret_name) 14 | 15 | password, username, host, port = get_secret_value(creds_secret_name) 16 | 17 | tenant_password, tenant_username, tenant_host, tenant_port = get_secret_value(tenant_secret_name) 18 | print(tenant_password, tenant_username, tenant_host, tenant_port) 19 | 20 | connection = psycopg.connect(dbname='postgres', 21 | host=host, 22 | port=port, 23 | user=username, 24 | password=password, 25 | autocommit=True) 26 | 27 | if tenant_state == 'PROVISION': 28 | query(connection, "CREATE DATABASE {0};".format(tenant_id)) 29 | connection.close() 30 | 31 | connection = psycopg.connect(dbname=tenant_id, 32 | host=host, 33 | port=port, 34 | user=username, 35 | password=password, 36 | autocommit=True) 37 | 38 | 39 | with open(os.path.join(os.path.dirname(__file__), 'tenant-provisioning.sql'), 'r') as f: 40 | sql_script = f.read() 41 | 42 | sql_script = sql_script.replace("",tenant_id).replace("", tenant_password) 43 | print(sql_script) 44 | 45 | query(connection, sql_script) 46 | connection.close() 47 | elif tenant_state == 'DE-PROVISION': 48 | query(connection, "DROP DATABASE {0};".format(tenant_id)) 49 | query(connection, "DROP user {0};".format(tenant_id)) 50 | connection.close() 51 | else: 52 | return { 53 | 'status': 'ERROR', 54 | 'err': 'INVALID TENANT STATE', 55 | 'message': 'INVALID TENANT STATE' 56 | } 57 | return { 58 | 'status': 'OK', 59 | 'results': "tenant created" 60 | } 61 | except Exception as err: 62 | return { 63 | 'status': 'ERROR', 64 | 'err': str(err), 65 | 'message': str(err) 66 | } 67 | 68 | def query(connection, sql): 69 | connection.execute(sql) 70 | 71 | def get_secret_value(secret_id): 72 | response = secrets_manager.get_secret_value(SecretId=secret_id) 73 | secret_value = response['SecretString'] 74 | 75 | #convert string to json 76 | secret_value = eval(secret_value) 77 | 78 | password = secret_value["password"] 79 | username = secret_value["username"] 80 | host = secret_value["host"] 81 | port = secret_value["port"] 82 | 83 | return password, username, host, port 84 | 85 | -------------------------------------------------------------------------------- /lib/saas-management/cell-provisioning-system/src/lambdas/persistCellDetails/persistCellDetails.py: -------------------------------------------------------------------------------- 1 | import json 2 | import boto3 3 | import logging 4 | import os 5 | 6 | eventbridge_client = boto3.client('events') 7 | 8 | logger = logging.getLogger() 9 | logger.setLevel(logging.INFO) 10 | 11 | def handler(event, context): 12 | 13 | logger.info('Received event: %s', event) 14 | 15 | # Send a message to EventBridge 16 | cell_management_bus = os.environ.get('CELL_MANAGEMENT_BUS') 17 | 18 | if event.get("Error") is None: 19 | build_outputs = event.get("Build").get("ExportedEnvironmentVariables") 20 | eventBridge_message = { 21 | 'Source': 'cellManagement.cellCreated', 22 | 'DetailType': 'CellDetails', 23 | 'EventBusName': cell_management_bus 24 | } 25 | 26 | detail = {} 27 | 28 | for item in build_outputs: 29 | name = item['Name'] 30 | value = item['Value'] 31 | json_to_append = {name:value} 32 | detail.update(json_to_append) 33 | 34 | eventBridge_message.update({'Detail': json.dumps(detail)}) 35 | 36 | logger.info('EventBridge Message Generated: %s', eventBridge_message) 37 | 38 | eventbridge_response = eventbridge_client.put_events( 39 | Entries=[ 40 | eventBridge_message 41 | ] 42 | ) 43 | 44 | # Check if the event was sent successfully 45 | if eventbridge_response['FailedEntryCount'] == 0: 46 | return { 47 | 'statusCode': 200, 48 | } 49 | else: 50 | logger.error(f"Failed to send event to EventBridge") 51 | logger.error(eventbridge_response) 52 | return { 53 | 'statusCode': 500, 54 | 'body': json.dumps('Failed to send event to EventBridge') 55 | } 56 | else: 57 | cause = json.loads(event.get("Cause")) 58 | build_details = cause.get("Build") 59 | exported_vars = build_details.get("ExportedEnvironmentVariables") 60 | eventBridge_message = { 61 | 'Source': 'cellManagement.cellCreationError', 62 | 'DetailType': 'CellDetails', 63 | 'EventBusName': cell_management_bus 64 | } 65 | 66 | detail = {} 67 | 68 | for item in exported_vars: 69 | name = item['Name'] 70 | value = item['Value'] 71 | json_to_append = {name:value} 72 | detail.update(json_to_append) 73 | 74 | eventBridge_message.update({'Detail': json.dumps(detail)}) 75 | 76 | logger.info('EventBridge Message Generated: %s', eventBridge_message) 77 | 78 | eventbridge_response = eventbridge_client.put_events( 79 | Entries=[ 80 | eventBridge_message 81 | ] 82 | ) 83 | 84 | # Check if the event was sent successfully 85 | if eventbridge_response['FailedEntryCount'] == 0: 86 | return { 87 | 'statusCode': 200, 88 | } 89 | else: 90 | logger.error(f"Failed to send event to EventBridge") 91 | logger.error(eventbridge_response) 92 | return { 93 | 'statusCode': 500, 94 | 'body': json.dumps('Failed to send event to EventBridge') 95 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /lib/saas-management/cell-provisioning-system/src/lambdas/persistTenantDetails/persistTenantDetails.py: -------------------------------------------------------------------------------- 1 | import json 2 | import boto3 3 | import logging 4 | import os 5 | 6 | eventbridge_client = boto3.client('events') 7 | 8 | logger = logging.getLogger() 9 | logger.setLevel(logging.INFO) 10 | 11 | def handler(event, context): 12 | 13 | logger.info('Received event: %s', event) 14 | 15 | # Send a message to EventBridge 16 | cell_management_bus = os.environ.get('CELL_MANAGEMENT_BUS') 17 | 18 | if event.get("Error") is None: 19 | build_outputs = event.get("Build").get("ExportedEnvironmentVariables") 20 | eventBridge_message = { 21 | 'Source': 'cellManagement.tenantCreated', 22 | 'DetailType': 'TenantDetails', 23 | 'EventBusName': cell_management_bus 24 | } 25 | 26 | detail = {} 27 | 28 | for item in build_outputs: 29 | name = item['Name'] 30 | value = item['Value'] 31 | json_to_append = {name:value} 32 | detail.update(json_to_append) 33 | 34 | eventBridge_message.update({'Detail': json.dumps(detail)}) 35 | 36 | logger.info('EventBridge Message Generated: %s', eventBridge_message) 37 | 38 | eventbridge_response = eventbridge_client.put_events( 39 | Entries=[ 40 | eventBridge_message 41 | ] 42 | ) 43 | 44 | # Check if the event was sent successfully 45 | if eventbridge_response['FailedEntryCount'] == 0: 46 | return { 47 | 'statusCode': 200, 48 | } 49 | else: 50 | logger.error(f"Failed to send event to EventBridge") 51 | logger.error(eventbridge_response) 52 | return { 53 | 'statusCode': 500, 54 | 'body': json.dumps('Failed to send event to EventBridge') 55 | } 56 | else: 57 | cause = json.loads(event.get("Cause")) 58 | build_details = cause.get("Build") 59 | exported_vars = build_details.get("ExportedEnvironmentVariables") 60 | eventBridge_message = { 61 | 'Source': 'cellManagement.tenantCreationError', 62 | 'DetailType': 'TenantDetails', 63 | 'EventBusName': cell_management_bus 64 | } 65 | 66 | detail = {} 67 | 68 | for item in exported_vars: 69 | name = item['Name'] 70 | value = item['Value'] 71 | json_to_append = {name:value} 72 | detail.update(json_to_append) 73 | 74 | eventBridge_message.update({'Detail': json.dumps(detail)}) 75 | 76 | logger.info('EventBridge Message Generated: %s', eventBridge_message) 77 | 78 | eventbridge_response = eventbridge_client.put_events( 79 | Entries=[ 80 | eventBridge_message 81 | ] 82 | ) 83 | 84 | # Check if the event was sent successfully 85 | if eventbridge_response['FailedEntryCount'] == 0: 86 | return { 87 | 'statusCode': 200, 88 | } 89 | else: 90 | logger.error(f"Failed to send event to EventBridge") 91 | logger.error(eventbridge_response) 92 | return { 93 | 'statusCode': 500, 94 | 'body': json.dumps('Failed to send event to EventBridge') 95 | } -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/DescribeTenant/describeTenant.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | import json 4 | import os 5 | import boto3 6 | import logging 7 | 8 | dynamodb = boto3.resource('dynamodb') 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | def handler(event, context): 14 | 15 | # Log the entire event 16 | logger.info('Received event: %s', event) 17 | 18 | # Extract the request body 19 | queryParams = event.get('queryStringParameters') 20 | if queryParams: 21 | try: 22 | cellId = queryParams.get('CellId') 23 | tenantId = queryParams.get('TenantId') 24 | logger.info('CellId: %s', cellId) 25 | logger.info('TenantId: %s', tenantId) 26 | except json.JSONDecodeError: 27 | logger.error('Invalid JSON in request body') 28 | return { 29 | 'statusCode': 400, 30 | 'body': json.dumps({'error': 'Invalid JSON in request body'}) 31 | } 32 | else: 33 | return describe_tenant(cellId, tenantId) 34 | else: 35 | logger.info('No TenantId found!') 36 | 37 | # Process the request and generate a response 38 | response = { 39 | 'statusCode': 200, 40 | 'body': json.dumps({'message': 'This is a response from the DescribeTenant Lambda!'}) 41 | } 42 | 43 | # Log the response 44 | logger.info('Response: %s', response) 45 | 46 | return response 47 | 48 | def describe_tenant(cell_id, tenant_id): 49 | 50 | tenant_management_table = os.environ.get('TENANT_MANAGEMENT_TABLE') 51 | ddb_table = dynamodb.Table(tenant_management_table) 52 | try: 53 | ddb_response = ddb_table.get_item( 54 | Key={ 55 | 'PK': cell_id + "#" + tenant_id 56 | } 57 | ) 58 | except Exception as e: 59 | logger.error(f"Error retrieving tenant status: {str(e)}") 60 | return { 61 | 'statusCode': 500, 62 | 'body': json.dumps('Error retrieving tenant status') 63 | } 64 | 65 | if 'Item' in ddb_response: 66 | item = ddb_response['Item'] 67 | tenant_id = item.get('tenant_id') 68 | cell_id = item.get('cell_id') 69 | tenant_name = item.get('tenant_name') 70 | tenant_status = item.get('current_status') 71 | tenant_tier = item.get('tenant_tier') 72 | tenant_email = item.get('tenant_email'), 73 | tenant_listener_priority = item.get('tenant_listener_priority') 74 | 75 | return { 76 | 'statusCode': 200, 77 | 'body': json.dumps({ 78 | 'CellId': cell_id, 79 | 'TenantId': tenant_id, 80 | 'TenantName': tenant_name, 81 | 'Status': tenant_status, 82 | 'TenantTier': tenant_tier, 83 | 'TenantEmail': tenant_email, 84 | 'TenantListenerPriority': tenant_listener_priority 85 | }) 86 | } 87 | 88 | else: 89 | return { 90 | 'statusCode': 404, 91 | 'body': json.dumps(f'Tenant not found: {cell_id} & {tenant_id}') 92 | } -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/saas-cell-based-architecture.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 38 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 39 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 40 | "@aws-cdk/aws-route53-patters:useCertificate": true, 41 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 42 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 43 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 44 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 45 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 46 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 47 | "@aws-cdk/aws-redshift:columnId": true, 48 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 49 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 50 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 51 | "@aws-cdk/aws-kms:aliasNameRef": true, 52 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 53 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 54 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 55 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 56 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 57 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 58 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 59 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 60 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 61 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, 62 | "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, 63 | "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, 64 | "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, 65 | "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, 66 | "@aws-cdk/aws-eks:nodegroupNameAttribute": true, 67 | "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, 68 | "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, 69 | "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/cdk/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/app.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 38 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 39 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 40 | "@aws-cdk/aws-route53-patters:useCertificate": true, 41 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 42 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 43 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 44 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 45 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 46 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 47 | "@aws-cdk/aws-redshift:columnId": true, 48 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 49 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 50 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 51 | "@aws-cdk/aws-kms:aliasNameRef": true, 52 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 53 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 54 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 55 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 56 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 57 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 58 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 59 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 60 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 61 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, 62 | "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, 63 | "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, 64 | "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, 65 | "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, 66 | "@aws-cdk/aws-eks:nodegroupNameAttribute": true, 67 | "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, 68 | "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, 69 | "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/PersistCellMetadata/persistCellMetadata.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | import json 4 | import os 5 | import boto3 6 | import logging 7 | import json 8 | import boto3 9 | 10 | # Initialize clients 11 | dynamodb = boto3.resource('dynamodb') 12 | 13 | logger = logging.getLogger() 14 | logger.setLevel(logging.INFO) 15 | 16 | def handler(event, context): 17 | 18 | # Log the entire event 19 | logger.info('Received event: %s', event) 20 | 21 | source = event.get("source") 22 | 23 | # Extract the request body 24 | detail = event.get('detail') 25 | if source == "cellManagement.cellCreated": 26 | try: 27 | logger.info('details received: %s', json.dumps(detail)) 28 | logger.info('CELL_ID: %s', detail.get("CELL_ID")) 29 | logger.info('STACK_OUTPUTS: %s', detail.get("STACK_OUTPUTS")) 30 | except (json.JSONDecodeError, UnicodeDecodeError): 31 | logger.error('Invalid JSON or encoding in request body') 32 | return { 33 | 'statusCode': 400, 34 | 'body': json.dumps({'error': 'Invalid JSON or encoding in request body'}) 35 | } 36 | elif source == "cellManagement.cellCreationError": 37 | try: 38 | logger.info('error details received: %s', json.dumps(detail)) 39 | logger.info('CELL_ID: %s', detail.get("CELL_ID")) 40 | except (json.JSONDecodeError, UnicodeDecodeError): 41 | logger.error('Invalid JSON or encoding in request body') 42 | return { 43 | 'statusCode': 400, 44 | 'body': json.dumps({'error': 'Invalid JSON or encoding in request body'}) 45 | } 46 | else: 47 | logger.info('Wrong request body') 48 | return { 49 | 'statusCode': 400, 50 | 'body': json.dumps({'error': 'Wrong request body'}) 51 | } 52 | 53 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 54 | ddb_table = dynamodb.Table(cell_management_table) 55 | 56 | if source == "cellManagement.cellCreated": 57 | stack_name = "Cell-" + detail.get("CELL_ID") 58 | stack_outputs = json.loads(detail.get("STACK_OUTPUTS")) 59 | cloudformation_outputs = stack_outputs[stack_name] 60 | max_tenants_supported = int(cloudformation_outputs.get("CellTotalTenantsSupported")) 61 | cell_url = cloudformation_outputs.get("CellApiUrl") 62 | 63 | response = ddb_table.update_item( 64 | Key={ 65 | 'PK': detail.get("CELL_ID") 66 | }, 67 | UpdateExpression="set current_status = :cs, cell_url = :cu, cell_max_capacity = :cmc, cf_stack = :cfs, cf_metadata = :cm", 68 | ExpressionAttributeValues={ 69 | ':cs': 'available', 70 | ':cu': cell_url, 71 | ':cmc': max_tenants_supported, 72 | ':cfs': stack_name, 73 | ':cm': json.dumps(cloudformation_outputs) 74 | }, 75 | ReturnValues="UPDATED_NEW" 76 | ) 77 | return { 78 | 'statusCode': 200, 79 | 'body': json.dumps({'status': 'creating'}) 80 | } 81 | else: 82 | stack_name = "Cell-" + detail.get("CELL_ID") 83 | response = ddb_table.update_item( 84 | Key={ 85 | 'PK': detail.get("CELL_ID") 86 | }, 87 | UpdateExpression="set current_status = :cs, cf_stack = :cfs", 88 | ExpressionAttributeValues={ 89 | ':cs': 'failed', 90 | ':cfs': stack_name 91 | }, 92 | ReturnValues="UPDATED_NEW" 93 | ) 94 | return { 95 | 'statusCode': 200, 96 | 'body': json.dumps({'status': 'error'}) 97 | } -------------------------------------------------------------------------------- /scripts/test_createcell.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | if [ -z "$2" ]; then 9 | echo "Usage: $0 " 10 | exit 1 11 | fi 12 | 13 | if [ -z "$3" ]; then 14 | echo "Usage: $0 " 15 | exit 1 16 | fi 17 | 18 | 19 | CELL_NAME=$1 20 | CELL_SIZE=$2 21 | WAVE_NUMBER=$3 22 | 23 | echo "Deploying Cell $CELL_NAME with Size $CELL_SIZE" 24 | 25 | CELL_MANAGEMENT_STACK_NAME="CellManagementSystem" 26 | 27 | USER_POOL_ID=$(aws cloudformation describe-stacks --stack-name $CELL_MANAGEMENT_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='UserPoolId'].OutputValue" | jq -r '.[0]') 28 | CLIENT_ID=$(aws cloudformation describe-stacks --stack-name $CELL_MANAGEMENT_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='UserPoolClientId'].OutputValue" | jq -r '.[0]') 29 | 30 | USER="admin" 31 | PASSWORD="#CellBased1234" 32 | 33 | echo "CLIENT_ID: ${CLIENT_ID}" 34 | echo "USER_POOL_ID: ${USER_POOL_ID}" 35 | 36 | # required in order to initiate-auth 37 | UPDATE_USER_POOL_AUTH=$(aws cognito-idp update-user-pool-client \ 38 | --user-pool-id "$USER_POOL_ID" \ 39 | --client-id "$CLIENT_ID" \ 40 | --explicit-auth-flows USER_PASSWORD_AUTH) 41 | 42 | # check if user exists 43 | DATA=$((aws cognito-idp list-users \ 44 | --user-pool-id "$USER_POOL_ID" \ 45 | --filter "username = \"$USER\"" \ 46 | --output json 47 | ) | jq -r '.Users[0].Username') 48 | echo "DATA: ${DATA}" 49 | 50 | if [ "$DATA" != "$USER" ]; then 51 | # create user 52 | CREATE_ADMIN=$(aws cognito-idp admin-create-user \ 53 | --user-pool-id "$USER_POOL_ID" \ 54 | --username "$USER") 55 | 56 | 57 | # remove need for password reset 58 | SET_PASSWORD=$(aws cognito-idp admin-set-user-password \ 59 | --user-pool-id "$USER_POOL_ID" \ 60 | --username "$USER" \ 61 | --password "$PASSWORD" \ 62 | --permanent) 63 | fi 64 | 65 | # get credentials for user 66 | AUTHENTICATION_RESULT=$(aws cognito-idp initiate-auth \ 67 | --auth-flow USER_PASSWORD_AUTH \ 68 | --client-id "${CLIENT_ID}" \ 69 | --auth-parameters "USERNAME=${USER},PASSWORD='${PASSWORD}'" \ 70 | --query 'AuthenticationResult') 71 | 72 | ID_TOKEN=$(echo "$AUTHENTICATION_RESULT" | jq -r '.IdToken') 73 | echo "ID_TOKEN: ${ID_TOKEN}" 74 | 75 | 76 | CELL_MANAGEMENT_API_ENDPOINT=$(aws cloudformation describe-stacks \ 77 | --stack-name "$CELL_MANAGEMENT_STACK_NAME" \ 78 | --query "Stacks[0].Outputs[?contains(OutputKey,'CellManagementApiEndpoint')].OutputValue" \ 79 | --output text) 80 | echo "CELL_MANAGEMENT_API_ENDPOINT: ${CELL_MANAGEMENT_API_ENDPOINT}" 81 | 82 | CELL_ID=$(curl --request POST \ 83 | --url "${CELL_MANAGEMENT_API_ENDPOINT}CreateCell" \ 84 | --header "Authorization: Bearer ${ID_TOKEN}" \ 85 | --header 'content-type: application/json' \ 86 | --data "{\"CellName\":\"$CELL_NAME\",\"CellSize\":\"$CELL_SIZE\",\"WaveNumber\": $WAVE_NUMBER}" \ 87 | | jq -r '.CellId') 88 | 89 | echo "CELL ID: ${CELL_ID}" 90 | 91 | # making cell id available to the next script 92 | echo $CELL_ID > cell_id.txt 93 | 94 | echo "Waiting for Cell to be created" 95 | sleep 10 96 | 97 | CELL_STATUS="creating" 98 | 99 | while [[ "$CELL_STATUS" != "available" && "$CELL_STATUS" != "failed" ]] 100 | do 101 | sleep 30 102 | CELL_STATUS=$(curl -s --request GET \ 103 | --url "${CELL_MANAGEMENT_API_ENDPOINT}DescribeCell?CellId=${CELL_ID}" \ 104 | --header "Authorization: Bearer ${ID_TOKEN}" \ 105 | --header 'Accept: application/json' \ 106 | | jq -r '.Status') 107 | echo "Cell ${CELL_ID}, status ${CELL_STATUS}" 108 | done 109 | -------------------------------------------------------------------------------- /bin/saas-cell-based-architecture.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { AwsSolutionsChecks } from 'cdk-nag' 3 | import { Aspects, App, Environment } from 'aws-cdk-lib'; 4 | import { CellManagementSystem } from '../lib/saas-management/cell-management-system/cell-management-system-stack'; 5 | import { CommonCellRouter } from '../lib/application-plane/common-components/cell-router/common-cell-router-stack'; 6 | import { CellProvisioningSystem } from '../lib/saas-management/cell-provisioning-system/cell-provisioning-system-stack'; 7 | import { CommonObservability } from '../lib/application-plane/common-components/observability/observability-stack'; 8 | import { Bridge } from '../lib/saas-management/bridge/bridge-stack'; 9 | 10 | //app = cdk.App(context={ "@aws-cdk/core:bootstrapQualifier": helper_functions.get_qualifier()} ) 11 | const app = new App(); 12 | 13 | /* 14 | * Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 15 | * 16 | * Comment out this line to unblock deployment 17 | */ 18 | Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })) 19 | 20 | 21 | /* 22 | * Create the components that bridge the SAAS control plane and 23 | * application plane. This includes the EventBridge EventBus, 24 | * S3 bucket for application plane source archives and SSM Params 25 | */ 26 | let bridgeStack = new Bridge(app, 'Bridge', { 27 | description: "Contains integration components used for communication between the Cell Management System and the Cell Provisioning System.", 28 | env: { 29 | account: process.env.CDK_DEFAULT_ACCOUNT, 30 | region: process.env.CDK_DEFAULT_REGION 31 | }, 32 | }); 33 | 34 | /** 35 | * Create the Common Cell Router components 36 | */ 37 | let cellRouterStack = new CommonCellRouter(app, 'CellRouter', { 38 | description: "Thinnest possible routing later, used for deterministic routing of api requests into individual cells.", 39 | env: { 40 | account: process.env.CDK_DEFAULT_ACCOUNT, 41 | region: 'us-east-1' 42 | }, 43 | }); 44 | 45 | /** 46 | * Common observability components 47 | */ 48 | let commonObservabilityStack = new CommonObservability(app, 'CommonObservability',{ 49 | description: "Contains common observability resources for the solution", 50 | distributionId: cellRouterStack.distributionId, 51 | env: { 52 | account: process.env.CDK_DEFAULT_ACCOUNT, 53 | region: process.env.CDK_DEFAULT_REGION 54 | }, 55 | crossRegionReferences: true 56 | }); 57 | 58 | /** 59 | * Create the Cell Management system used for managing Cells and Tenants 60 | */ 61 | let cellManagementSystemStack = new CellManagementSystem(app, 'CellManagementSystem',{ 62 | description: "Cell management system, used for creation and management of cells and tenants.", 63 | cellToTenantKvsArn: cellRouterStack.cellToTenantKvsArn, 64 | eventBusArn: bridgeStack.orchestrationEventBus.eventBusArn, 65 | versionSsmParameter: bridgeStack.imageVersionParam, 66 | crossRegionReferences: true, 67 | env: { 68 | account: process.env.CDK_DEFAULT_ACCOUNT, 69 | region: process.env.CDK_DEFAULT_REGION 70 | }, 71 | }); 72 | 73 | /** 74 | * Create the Cell Provisioning System used for deploying cells and tenants 75 | */ 76 | let cellProvisioningSystemStack = new CellProvisioningSystem(app, 'CellProvisioningSystem', { 77 | description: "Cell provisioning system, used for deployment of cells and tenants.", 78 | orchestrationBus: bridgeStack.orchestrationEventBus, 79 | cellManagementTable: cellManagementSystemStack.cellManagementTable, 80 | s3LoggingBucketArn: bridgeStack.s3LogBucketArn, 81 | s3CellSourceBucketArn: bridgeStack.cellSourceBucketArn, 82 | aggregateHttp5xxAlarmName: commonObservabilityStack.aggregateHttp5xxAlarmName, 83 | env: { 84 | account: process.env.CDK_DEFAULT_ACCOUNT, 85 | region: process.env.CDK_DEFAULT_REGION 86 | }, 87 | }); -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/CreateCell/createCell.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import boto3 4 | import logging 5 | import json 6 | import boto3 7 | import string 8 | import random 9 | 10 | # Initialize clients 11 | eventbridge_client = boto3.client('events') 12 | dynamodb = boto3.resource('dynamodb') 13 | 14 | logger = logging.getLogger() 15 | logger.setLevel(logging.INFO) 16 | 17 | def handler(event, context): 18 | 19 | # Log the entire event 20 | logger.info('Received event: %s', event) 21 | 22 | # Extract the request body 23 | body = event.get('body') 24 | if body: 25 | try: 26 | data = json.loads(body) 27 | logger.info('Request body: %s', data) 28 | 29 | # Get cellName and cellSize from the request body 30 | cell_name = data.get('CellName') 31 | cell_size = data.get('CellSize') 32 | wave_number = data.get('WaveNumber') 33 | except (json.JSONDecodeError, UnicodeDecodeError): 34 | logger.error('Invalid JSON or encoding in request body') 35 | return { 36 | 'statusCode': 400, 37 | 'body': json.dumps({'error': 'Invalid JSON or encoding in request body'}) 38 | } 39 | else: 40 | logger.info('Wrong request body') 41 | return { 42 | 'statusCode': 400, 43 | 'body': json.dumps({'error': 'Wrong request body'}) 44 | } 45 | 46 | # ID's need to start with a letter 47 | generated_cell_id_prefix = random.SystemRandom().choice(string.ascii_lowercase) 48 | # Generate a random string containing lowercase letters and numbers only 49 | generated_cell_id = generated_cell_id_prefix + "".join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(6)) 50 | 51 | response = create_cell(cell_id=generated_cell_id, cell_name=cell_name, cell_size=cell_size, wave_number=wave_number) 52 | 53 | # Log the response 54 | logger.info('Response: %s', response) 55 | 56 | return response 57 | 58 | 59 | def create_cell(cell_id, cell_name, cell_size, wave_number): 60 | # Send a message to EventBridge 61 | cell_management_bus = os.environ.get('CELL_MANAGEMENT_BUS') 62 | eventbridge_response = eventbridge_client.put_events( 63 | Entries=[ 64 | { 65 | 'Source': 'cellManagement.createCell', 66 | 'DetailType': 'CellData', 67 | 'Detail': json.dumps({ 68 | 'event_type': 'create_cell', 69 | 'cell_id': cell_id, 70 | 'cell_name': cell_name, 71 | 'cell_size': cell_size, 72 | 'wave_number': wave_number 73 | }), 74 | 'EventBusName': cell_management_bus 75 | } 76 | ] 77 | ) 78 | 79 | # Check if the event was sent successfully 80 | if eventbridge_response['FailedEntryCount'] == 0: 81 | # Store the metadata in DynamoDB 82 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 83 | ddb_table = dynamodb.Table(cell_management_table) 84 | ddb_table.put_item( 85 | Item={ 86 | 'PK': cell_id, 87 | 'cell_name': cell_name, 88 | 'cell_size': cell_size, 89 | 'wave_number': wave_number, 90 | 'current_status': 'creating', 91 | 'cell_utilization': 0 92 | } 93 | ) 94 | return { 95 | 'statusCode': 200, 96 | 'body': json.dumps({'CellId': cell_id, 'Status': 'creating'}) 97 | } 98 | else: 99 | logger.error(f"Failed to send event to EventBridge") 100 | return { 101 | 'statusCode': 500, 102 | 'body': json.dumps('Failed to send event to EventBridge') 103 | } 104 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/DescribeCell/describeCell.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | import json 4 | import os 5 | import boto3 6 | import logging 7 | 8 | dynamodb = boto3.resource('dynamodb') 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | def handler(event, context): 14 | 15 | # Log the entire event 16 | logger.info('Received event: %s', event) 17 | 18 | # Extract the request body 19 | queryParams = event.get('queryStringParameters') 20 | if queryParams: 21 | try: 22 | cellId = queryParams.get('CellId') 23 | logger.info('CellId: %s', cellId) 24 | except json.JSONDecodeError: 25 | logger.error('Invalid JSON in request body') 26 | return { 27 | 'statusCode': 400, 28 | 'body': json.dumps({'error': 'Invalid JSON in request body'}) 29 | } 30 | else: 31 | return describe_cell(cellId) 32 | else: 33 | logger.info('No CellId found!') 34 | 35 | # Process the request and generate a response 36 | response = { 37 | 'statusCode': 200, 38 | 'body': json.dumps({'message': 'This is a response from the DescribeCell Lambda!'}) 39 | } 40 | 41 | # Log the response 42 | logger.info('Response: %s', response) 43 | 44 | return response 45 | 46 | def describe_cell(cell_id): 47 | 48 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 49 | ddb_table = dynamodb.Table(cell_management_table) 50 | try: 51 | ddb_response = ddb_table.get_item( 52 | Key={ 53 | 'PK': cell_id 54 | } 55 | ) 56 | except Exception as e: 57 | logger.error(f"Error retrieving cell status: {str(e)}") 58 | return { 59 | 'statusCode': 500, 60 | 'body': json.dumps('Error retrieving tenant status') 61 | } 62 | 63 | if 'Item' in ddb_response: 64 | item = ddb_response['Item'] 65 | cell_name = item.get('cell_name') 66 | cell_status = item.get('current_status') 67 | cell_utilization = item.get('cell_utilization','0') 68 | cell_size = item.get('cell_size','not available') 69 | wave_number = item.get('wave_number') 70 | 71 | if cell_status == 'creating': 72 | return { 73 | 'statusCode': 200, 74 | 'body': json.dumps({ 75 | 'CellId': cell_id, 76 | 'CellName': cell_name, 77 | 'Status': cell_status, 78 | 'CellSize': cell_size, 79 | 'CellCurrentUtilization': int(cell_utilization), 80 | 'DeploymentWave': int(wave_number) 81 | }) 82 | } 83 | else: 84 | cell_url = item.get('cell_url','not available') 85 | cell_max_capacity = item.get('cell_max_capacity','0') 86 | return { 87 | 'statusCode': 200, 88 | 'body': json.dumps({ 89 | 'CellId': cell_id, 90 | 'CellName': cell_name, 91 | 'Status': cell_status, 92 | 'CellUrl': cell_url, 93 | 'CellSize': cell_size, 94 | 'CellCurrentUtilization': int(cell_utilization), 95 | 'CellMaxSize': int(cell_max_capacity), 96 | 'DeploymentWave': int(wave_number) 97 | }) 98 | } 99 | 100 | else: 101 | return { 102 | 'statusCode': 404, 103 | 'body': json.dumps(f'Cell not found: {cell_id}') 104 | } -------------------------------------------------------------------------------- /scripts/test_createtenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | if [ -z "$2" ]; then 9 | echo "Usage: $1 " 10 | exit 1 11 | fi 12 | 13 | if [ -z "$3" ]; then 14 | echo "Usage: $0 " 15 | exit 1 16 | fi 17 | 18 | if [ -z "$4" ]; then 19 | echo "Usage: $0 " 20 | exit 1 21 | fi 22 | 23 | 24 | CELL_ID=$1 25 | TENANT_NAME=$2 26 | TENANT_EMAIL=$3 27 | TENANT_TIER=$4 28 | 29 | echo "Deploying tenant $TENANT_NAME in cell $CELL_ID" 30 | 31 | CELL_MANAGEMENT_STACK_NAME="CellManagementSystem" 32 | 33 | USER_POOL_ID=$(aws cloudformation describe-stacks --stack-name $CELL_MANAGEMENT_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='UserPoolId'].OutputValue" | jq -r '.[0]') 34 | CLIENT_ID=$(aws cloudformation describe-stacks --stack-name $CELL_MANAGEMENT_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='UserPoolClientId'].OutputValue" | jq -r '.[0]') 35 | 36 | USER="admin" 37 | PASSWORD="#CellBased1234" 38 | 39 | echo "CLIENT_ID: ${CLIENT_ID}" 40 | echo "USER_POOL_ID: ${USER_POOL_ID}" 41 | 42 | # required in order to initiate-auth 43 | UPDATE_USER_POOL_AUTH=$(aws cognito-idp update-user-pool-client \ 44 | --user-pool-id "$USER_POOL_ID" \ 45 | --client-id "$CLIENT_ID" \ 46 | --explicit-auth-flows USER_PASSWORD_AUTH) 47 | 48 | # check if user exists 49 | DATA=$((aws cognito-idp list-users \ 50 | --user-pool-id "$USER_POOL_ID" \ 51 | --filter "username = \"$USER\"" \ 52 | --output json 53 | ) | jq -r '.Users[0].Username') 54 | echo "DATA: ${DATA}" 55 | 56 | if [ "$DATA" != "$USER" ]; then 57 | # create user 58 | CREATE_ADMIN=$(aws cognito-idp admin-create-user \ 59 | --user-pool-id "$USER_POOL_ID" \ 60 | --username "$USER" ) 61 | 62 | 63 | # remove need for password reset 64 | SET_PASSWORD=$(aws cognito-idp admin-set-user-password \ 65 | --user-pool-id "$USER_POOL_ID" \ 66 | --username "$USER" \ 67 | --password "$PASSWORD" \ 68 | --permanent) 69 | fi 70 | 71 | # get credentials for user 72 | AUTHENTICATION_RESULT=$(aws cognito-idp initiate-auth \ 73 | --auth-flow USER_PASSWORD_AUTH \ 74 | --client-id "${CLIENT_ID}" \ 75 | --auth-parameters "USERNAME=${USER},PASSWORD='${PASSWORD}'" \ 76 | --query 'AuthenticationResult') 77 | 78 | ID_TOKEN=$(echo "$AUTHENTICATION_RESULT" | jq -r '.IdToken') 79 | echo "ID_TOKEN: ${ID_TOKEN}" 80 | 81 | 82 | CELL_MANAGEMENT_API_ENDPOINT=$(aws cloudformation describe-stacks \ 83 | --stack-name "$CELL_MANAGEMENT_STACK_NAME" \ 84 | --query "Stacks[0].Outputs[?contains(OutputKey,'CellManagementApiEndpoint')].OutputValue" \ 85 | --output text) 86 | echo "CELL_MANAGEMENT_API_ENDPOINT: ${CELL_MANAGEMENT_API_ENDPOINT}" 87 | 88 | # echo "creating tenant..." 89 | 90 | TENANT_ID=$(curl --request POST \ 91 | --url "${CELL_MANAGEMENT_API_ENDPOINT}AssignTenantToCell" \ 92 | --header "Authorization: Bearer ${ID_TOKEN}" \ 93 | --header 'content-type: application/json' \ 94 | --data "{\"CellId\":\"$CELL_ID\",\"TenantName\":\"$TENANT_NAME\",\"TenantEmail\": \"$TENANT_EMAIL\",\"TenantTier\":\"$TENANT_TIER\"}" \ 95 | | jq -r '.TenantId') 96 | 97 | echo "TENANT ID: ${TENANT_ID}" 98 | echo $TENANT_ID > tenant_id.txt 99 | 100 | 101 | TENANT_STATUS="creating" 102 | 103 | while [[ "$TENANT_STATUS" != "available" && "$TENANT_STATUS" != "failed" ]] 104 | do 105 | sleep 30 106 | TENANT_STATUS=$(curl -s --request GET \ 107 | --url "${CELL_MANAGEMENT_API_ENDPOINT}DescribeTenant?CellId=${CELL_ID}&TenantId=${TENANT_ID}" \ 108 | --header "Authorization: Bearer ${ID_TOKEN}" \ 109 | --header 'Accept: application/json' \ 110 | | jq -r '.Status') 111 | echo "CellId ${CELL_ID}, TenantID ${TENANT_ID}, status ${TENANT_STATUS}" 112 | done 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/CapacityObserver/capacityObserver.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import os 4 | import boto3 5 | import logging 6 | from aws_embedded_metrics import metric_scope 7 | from aws_embedded_metrics.storage_resolution import StorageResolution 8 | from aws_embedded_metrics.config import get_config 9 | from botocore.exceptions import ClientError 10 | 11 | logger = logging.getLogger() 12 | logger.setLevel(logging.INFO) 13 | 14 | Config = get_config() 15 | Config.service_type = "CellObserver" 16 | 17 | DYNAMO_CELL_MANAGEMENT_TABLE = os.environ.get('CELL_MANAGEMENT_TABLE') 18 | 19 | dynamodb = boto3.resource('dynamodb') 20 | ddb_table = dynamodb.Table(DYNAMO_CELL_MANAGEMENT_TABLE) 21 | 22 | def handler(event, context): 23 | 24 | # Log the entire event 25 | logger.debug('Received event: %s', event) 26 | 27 | response = list_and_process_cells() 28 | 29 | # Log the response 30 | logger.info('Response: %s', response) 31 | return response 32 | 33 | def list_and_process_cells(): 34 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 35 | table = dynamodb.Table(cell_management_table) 36 | cells = [] 37 | scan_kwargs = { 38 | "ProjectionExpression": "PK, cell_name, cell_max_capacity, cell_utilization, current_status" 39 | } 40 | try: 41 | done = False 42 | start_key = None 43 | while not done: 44 | if start_key: 45 | scan_kwargs["ExclusiveStartKey"] = start_key 46 | response = table.scan(**scan_kwargs) 47 | dynamo_response = response.get("Items",[]) 48 | # Iterate over a copy of the list to avoid semgrep list-modify-while-iterate rule. 49 | for item in dynamo_response[:]: 50 | # We're only interested in cells, so ignore the tenants (PK's with a #) 51 | if "#" in item['PK']: 52 | dynamo_response.remove(item) 53 | # If the cell is provisioning or failed to provision, ignore 54 | elif "available" not in item['current_status']: 55 | dynamo_response.remove(item) 56 | else: 57 | item['CellId'] = item.pop('PK') 58 | item['CellName'] = item.pop('cell_name') 59 | item['Status'] = item.pop('current_status') 60 | item['CellMaxSize'] = item.pop('cell_max_capacity') 61 | item['CellUtilization'] = item.pop('cell_utilization') 62 | cells.extend(response.get("Items", [])) 63 | start_key = response.get("LastEvaluatedKey", None) 64 | done = start_key is None 65 | except Exception as e: 66 | logger.error("Couldn't scan for cells: %s",e) 67 | raise 68 | else: 69 | for cell in cells: 70 | logger.info("Cell: %s", cell) 71 | try: 72 | emit_cell_metrics(cell) 73 | except Exception as e: 74 | logger.error("Error processing metrics for cell: %s", e) 75 | 76 | # Process the request and generate a response 77 | response = { 78 | 'statusCode': 200, 79 | 'body': { 80 | "processed": True 81 | } 82 | } 83 | return response 84 | 85 | @metric_scope 86 | def emit_cell_metrics(cell_details,metrics): 87 | try: 88 | cell_id = cell_details['CellId'] 89 | cell_name = cell_details['CellName'] 90 | cell_max_size = cell_details['CellMaxSize'] 91 | cell_utilization = cell_details['CellUtilization'] 92 | 93 | # Emit metrics 94 | logger.info("Emitting metrics for cell: %s", cell_id) 95 | logger.info("Cell name: %s", cell_name) 96 | logger.info("Cell max size: %s", cell_max_size) 97 | logger.info("Cell utilization: %s", cell_utilization) 98 | 99 | # Emit metrics 100 | metrics.reset_dimensions(False) 101 | metrics.set_namespace("CellManagement") 102 | metrics.put_dimensions({ "CellId": cell_id,"CellName": cell_name }) 103 | metrics.put_metric("CellUtilization", int(cell_utilization), "Count", StorageResolution.STANDARD) 104 | metrics.put_metric("CellMaxSize", int(cell_max_size), "Count", StorageResolution.STANDARD) 105 | 106 | except Exception as e: 107 | logger.error("Error emitting metrics: %s", e) -------------------------------------------------------------------------------- /lib/application-plane/common-components/cell-router/src/functions/cell-router.js: -------------------------------------------------------------------------------- 1 | import cf from 'cloudfront'; 2 | 3 | const kvsHandle = cf.kvs(); 4 | 5 | //Set below to true to log out to Cloudwatch Logs 6 | const LOGGING_ENABLED = true; 7 | 8 | const ACCESS_DENIED_RESPONSE = { 9 | status: '401', 10 | statusDescription: 'Unauthorized', 11 | headers: { 12 | 'Content-Type': [{ 13 | key: 'Content-Type', 14 | value: 'application/json' 15 | }] 16 | }, 17 | body: "Not authorized" 18 | }; 19 | 20 | const INVALID_REQUEST_RESPONSE = { 21 | status: '400', 22 | statusDescription: 'Bad Request', 23 | headers: { 24 | 'Content-Type': [{ 25 | key: 'Content-Type', 26 | value: 'application/json' 27 | }] 28 | }, 29 | body: "Valid TenantId and Authorization headers are required" 30 | }; 31 | 32 | const TIMEOUT_CONFIG = { 33 | readTimeout: 30, 34 | connectionTimeout: 5 35 | }; 36 | 37 | const CUSTOM_ORIGIN_CONFIG = { 38 | port: 443, 39 | protocol: "https", 40 | sslProtocols: ["SSLv3","TLSv1","TLSv1.1","TLSv1.2"] 41 | }; 42 | 43 | const ORIGIN_ACCESS_CONTROL_CONFIG = { 44 | enabled: false 45 | }; 46 | 47 | async function handler(event) { 48 | 49 | let request = event.request; 50 | let headers = request.headers; 51 | 52 | if(!headers.authorization || !isValidAuthToken(headers.authorization.value)) 53 | { 54 | log('No authorization header provided'); 55 | return ACCESS_DENIED_RESPONSE; 56 | } 57 | 58 | const jwtTenantId = getTenantIdFromJwt(headers.authorization.value); 59 | 60 | try { 61 | const cellEndpoint = await kvsHandle.get(jwtTenantId,{format: "string"}); 62 | 63 | if(!cellEndpoint) { 64 | log(`No cell endpoint found for ${JSON.stringify(jwtTenantId)}`); 65 | return INVALID_REQUEST_RESPONSE; 66 | } 67 | 68 | const parsedEndpoint = parseTenantEndpoint(cellEndpoint); 69 | const encodedPath = encodeURIComponent(parsedEndpoint.path); 70 | 71 | log(`{TenantId: ${jwtTenantId}, Method: ${request.method}, CellEndpoint: ${cellEndpoint}, Hostname: ${parsedEndpoint.hostname}, EncodedPath: ${encodedPath}}`) 72 | 73 | const customOriginRequestObject = { 74 | domainName: parsedEndpoint.hostname, 75 | originPath: parsedEndpoint.path, 76 | timeouts: TIMEOUT_CONFIG, 77 | originAccessControlConfig: ORIGIN_ACCESS_CONTROL_CONFIG, 78 | customOriginConfig: CUSTOM_ORIGIN_CONFIG, 79 | }; 80 | 81 | //Set the tenantid header so that ALB can route to the correct tenant 82 | request.headers['tenantid'] = {value: jwtTenantId}; 83 | 84 | cf.updateRequestOrigin(customOriginRequestObject); 85 | log(`Successfully updated origin for tenant ${jwtTenantId} to ${cellEndpoint}`); 86 | return request; 87 | } catch (err) { 88 | log(`Kvs key lookup failed for ${jwtTenantId}: ${err.message || err}`); 89 | return INVALID_REQUEST_RESPONSE; 90 | } 91 | } 92 | 93 | function isValidAuthToken(authorization) { 94 | return typeof authorization === 'string' && authorization.length > 0; 95 | } 96 | 97 | function getTenantIdFromJwt(jwtToken) { 98 | // check token is present 99 | if (!jwtToken) { 100 | throw new Error('No token supplied'); 101 | } 102 | // check number of segments 103 | const segments = jwtToken.split('.'); 104 | if (segments.length !== 3) { 105 | throw new Error('Not enough or too many segments'); 106 | } 107 | 108 | const payloadSeg = segments[1]; 109 | // base64 decode and parse JSON 110 | const payload = JSON.parse(Buffer.from(payloadSeg, 'base64url')); 111 | const jwtTenantId = payload["custom:tenantId"]; 112 | return jwtTenantId; 113 | } 114 | 115 | function log(message) { 116 | if (LOGGING_ENABLED) { 117 | console.log(message); 118 | } 119 | } 120 | 121 | function parseTenantEndpoint(endpointUrl) { 122 | const URL_PREFIX = "https://"; 123 | const cleaned_endpoint = endpointUrl.substring(endpointUrl.indexOf(URL_PREFIX) + URL_PREFIX.length); 124 | const slashIndex = cleaned_endpoint.indexOf("/"); 125 | 126 | return { 127 | originalEndpoint: endpointUrl, 128 | hostname: cleaned_endpoint.substring(0, slashIndex), 129 | path: cleaned_endpoint.substring(slashIndex, cleaned_endpoint.length - 1) 130 | }; 131 | } -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/src/test/test_product.py: -------------------------------------------------------------------------------- 1 | # import unittest 2 | # from unittest.mock import patch, MagicMock 3 | # from flask import Flask, request, jsonify, Response 4 | # from product_media import app, get_bucketName_from_parameterstore, upload_to_s3 5 | # from botocore.exceptions import ClientError # Import added 6 | 7 | # class ProductMediaTestCase(unittest.TestCase): 8 | 9 | # def setUp(self): 10 | # self.app = app.test_client() 11 | # self.app.testing = True 12 | 13 | # @patch('product_media.get_bucketName_from_parameterstore') 14 | # def test_health_check(self, mock_get_bucketName): 15 | # response = self.app.get('/health') 16 | # self.assertEqual(response.status_code, 200) 17 | # self.assertEqual(response.json, {'status': 'UP', 'details': 'Application is running smoothly!!'}) 18 | 19 | # @patch('product_media.get_bucketName_from_parameterstore') 20 | # @patch('product_media.boto3.client') 21 | # def test_upload_file_success(self, mock_boto_client, mock_get_bucketName): 22 | # mock_get_bucketName.return_value = 'mock-bucket' 23 | # mock_s3 = MagicMock() 24 | # mock_boto_client.return_value = mock_s3 25 | 26 | # data = { 27 | # 'file': (open('test.txt', 'rb'), 'test.txt') 28 | # } 29 | # headers = { 30 | # 'tenantId': 'tenant123', 31 | # 'productId': 'product123' 32 | # } 33 | # response = self.app.post('/productmedia', data=data, headers=headers, content_type='multipart/form-data') 34 | # self.assertEqual(response.status_code, 200) 35 | # self.assertIn('successfully uploaded to S3 Bucket', response.json['message']) 36 | 37 | # @patch('product_media.get_bucketName_from_parameterstore') 38 | # @patch('product_media.boto3.client') 39 | # def test_upload_file_no_file(self, mock_boto_client, mock_get_bucketName): 40 | # response = self.app.post('/productmedia', content_type='multipart/form-data') 41 | # self.assertEqual(response.status_code, 400) 42 | # self.assertEqual(response.json, {"error": "No file part"}) 43 | 44 | # @patch('product_media.get_bucketName_from_parameterstore') 45 | # @patch('product_media.boto3.client') 46 | # def test_upload_file_no_tenantId(self, mock_boto_client, mock_get_bucketName): 47 | # mock_get_bucketName.return_value = 'mock-bucket' 48 | # mock_s3 = MagicMock() 49 | # mock_boto_client.return_value = mock_s3 50 | 51 | # data = { 52 | # 'file': (open('test.txt', 'rb'), 'test.txt') 53 | # } 54 | # headers = { 55 | # 'productId': 'product123' 56 | # } 57 | # response = self.app.post('/productmedia', data=data, headers=headers, content_type='multipart/form-data') 58 | # self.assertEqual(response.status_code, 400) 59 | # self.assertEqual(response.json, {"error": "tenantId header is required"}) 60 | 61 | # @patch('product_media.get_bucketName_from_parameterstore') 62 | # @patch('product_media.boto3.client') 63 | # def test_get_file_success(self, mock_boto_client, mock_get_bucketName): 64 | # mock_get_bucketName.return_value = 'mock-bucket' 65 | # mock_s3 = MagicMock() 66 | # mock_s3.get_object.return_value = {'Body': MagicMock(read=MagicMock(return_value=b'test content'))} 67 | # mock_boto_client.return_value = mock_s3 68 | 69 | # headers = { 70 | # 'tenantId': 'tenant123' 71 | # } 72 | # response = self.app.get('/productmedia/product123/test.txt', headers=headers) 73 | # self.assertEqual(response.status_code, 200) 74 | # self.assertEqual(response.data, b'test content') 75 | # self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename=test.txt') 76 | 77 | # @patch('product_media.get_bucketName_from_parameterstore') 78 | # @patch('product_media.boto3.client') 79 | # def test_get_file_not_found(self, mock_boto_client, mock_get_bucketName): 80 | # mock_get_bucketName.return_value = 'mock-bucket' 81 | # mock_s3 = MagicMock() 82 | # mock_s3.get_object.side_effect = ClientError( 83 | # {"Error": {"Code": "404", "Message": "Not Found"}}, 'get_object') 84 | # mock_boto_client.return_value = mock_s3 85 | 86 | # headers = { 87 | # 'tenantId': 'tenant123' 88 | # } 89 | # response = self.app.get('/productmedia/product123/test.txt', headers=headers) 90 | # self.assertEqual(response.status_code, 404) 91 | # self.assertEqual(response.json, {"error": "The requested file does not exist in S3"}) 92 | 93 | # if __name__ == '__main__': 94 | # unittest.main() 95 | -------------------------------------------------------------------------------- /lib/application-plane/common-components/cell-router/common-cell-router-stack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Stack, 3 | StackProps, 4 | RemovalPolicy, 5 | CfnOutput, 6 | Duration, 7 | PhysicalName 8 | } from 'aws-cdk-lib'; 9 | import { 10 | CacheCookieBehavior, 11 | CachePolicy, 12 | CacheHeaderBehavior, 13 | CacheQueryStringBehavior, 14 | Distribution, 15 | SecurityPolicyProtocol, 16 | AllowedMethods, 17 | OriginRequestPolicy, 18 | KeyValueStore, 19 | Function, 20 | FunctionCode, 21 | FunctionRuntime, 22 | FunctionEventType, 23 | } from 'aws-cdk-lib/aws-cloudfront'; 24 | import { S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins'; 25 | import { Bucket, BucketEncryption, BlockPublicAccess, BucketAccessControl } from 'aws-cdk-lib/aws-s3'; 26 | import { Construct } from 'constructs'; 27 | import { CdkNagUtils } from './src/utils/cdk-nag-utils' 28 | 29 | export class CommonCellRouter extends Stack { 30 | readonly distributionId: string; 31 | readonly cellToTenantKvsArn: string; 32 | 33 | constructor(scope: Construct, id: string, props: StackProps) { 34 | 35 | /** 36 | * call the parent constructor 37 | */ 38 | super(scope, id, props); 39 | 40 | // Handle CDK nag suppressions. 41 | CdkNagUtils.suppressCDKNag(this); 42 | 43 | const logBucket = new Bucket(this, 's3AccessLogBucket', { 44 | bucketName: PhysicalName.GENERATE_IF_NEEDED, 45 | removalPolicy: RemovalPolicy.DESTROY, 46 | autoDeleteObjects: true, 47 | versioned: false, 48 | accessControl: BucketAccessControl.LOG_DELIVERY_WRITE, 49 | enforceSSL: true, 50 | encryption: BucketEncryption.S3_MANAGED, 51 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 52 | lifecycleRules: [{ 53 | id: 'object retention policy', 54 | expiration: Duration.days(7) 55 | }] 56 | }); 57 | 58 | const s3DefaultOriginBucket = new Bucket(this, 'DefaultOriginBucket',{ 59 | removalPolicy: RemovalPolicy.DESTROY, 60 | autoDeleteObjects: true, 61 | enforceSSL: true, 62 | encryption: BucketEncryption.S3_MANAGED, 63 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 64 | serverAccessLogsPrefix: "logs/buckets/default-origin-bucket/", 65 | serverAccessLogsBucket: logBucket, 66 | }); 67 | 68 | // Create a Key Value Store 69 | const keyValueStore = new KeyValueStore(this, 'CellToTenantKeyValueStore', { 70 | keyValueStoreName: 'cell-tenant-mapping-store', 71 | comment: 'Store for mapping of cells to tenants', 72 | }); 73 | 74 | // Create the Routing CloudFront Function 75 | const routerFunction = new Function(this, 'CellRoutingFunction', { 76 | functionName: 'tenant-router', 77 | code: FunctionCode.fromFile({ 78 | filePath: 'lib/application-plane/common-components/cell-router/src/functions/cell-router.js' 79 | }), 80 | runtime: FunctionRuntime.JS_2_0, 81 | keyValueStore: keyValueStore, 82 | }); 83 | 84 | // Create a CloudFront Distribution 85 | const distribution = new Distribution(this, 'MyDistribution', { 86 | enableLogging: true, 87 | logBucket: logBucket, 88 | logFilePrefix: 'logs/cloudfront/cell-router/', 89 | minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021, 90 | defaultBehavior: { 91 | origin: S3BucketOrigin.withOriginAccessControl(s3DefaultOriginBucket), 92 | cachePolicy: new CachePolicy(this, 'ApiCachePolicy', { 93 | headerBehavior: CacheHeaderBehavior.allowList('tenantid','Authorization'), 94 | queryStringBehavior: CacheQueryStringBehavior.all(), 95 | cookieBehavior: CacheCookieBehavior.none(), 96 | minTtl: Duration.minutes(0), 97 | maxTtl: Duration.minutes(1), 98 | defaultTtl: Duration.minutes(0), 99 | enableAcceptEncodingGzip: true, 100 | enableAcceptEncodingBrotli: true, 101 | }), 102 | functionAssociations: [ 103 | { 104 | function: routerFunction, 105 | eventType: FunctionEventType.VIEWER_REQUEST, 106 | }, 107 | ], 108 | originRequestPolicy: OriginRequestPolicy.CORS_CUSTOM_ORIGIN, 109 | allowedMethods: AllowedMethods.ALLOW_ALL 110 | }, 111 | }); 112 | 113 | this.distributionId = distribution.distributionId 114 | this.cellToTenantKvsArn = keyValueStore.keyValueStoreArn; 115 | 116 | new CfnOutput(this, 'DistributionUrl', { 117 | value: distribution.domainName, 118 | description: 'The DNS name of CF Distribution', 119 | }); 120 | 121 | new CfnOutput(this, 'CellToTenantKvsArn', { 122 | value: keyValueStore.keyValueStoreArn, 123 | description: 'The Arn for the Cell to Tenant KVS', 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/PersistTenantMetadata/persistTenantMetadata.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | import json 4 | import os 5 | import boto3 6 | import logging 7 | import json 8 | import boto3 9 | 10 | # Initialize clients 11 | dynamodb = boto3.resource('dynamodb') 12 | 13 | logger = logging.getLogger() 14 | logger.setLevel(logging.INFO) 15 | 16 | def handler(event, context): 17 | 18 | # Log the entire event 19 | logger.info('Received event: %s', event) 20 | 21 | source = event.get("source") 22 | 23 | # Extract the request body 24 | detail = event.get('detail') 25 | if source == "cellManagement.tenantCreated": 26 | try: 27 | logger.info('details received: %s', json.dumps(detail)) 28 | logger.info('CELL_ID: %s', detail.get("CELL_ID")) 29 | logger.info('TENANT_ID: %s', detail.get("TENANT_ID")) 30 | logger.info('STACK_OUTPUTS: %s', detail.get("STACK_OUTPUTS")) 31 | except (json.JSONDecodeError, UnicodeDecodeError): 32 | logger.error('Invalid JSON or encoding in request body') 33 | return { 34 | 'statusCode': 400, 35 | 'body': json.dumps({'error': 'Invalid JSON or encoding in request body'}) 36 | } 37 | elif source == "cellManagement.tenantCreationError": 38 | try: 39 | logger.info('error details received: %s', json.dumps(detail)) 40 | logger.info('CELL_ID: %s', detail.get("CELL_ID")) 41 | logger.info('TENANT_ID: %s', detail.get("TENANT_ID")) 42 | except (json.JSONDecodeError, UnicodeDecodeError): 43 | logger.error('Invalid JSON or encoding in request body') 44 | return { 45 | 'statusCode': 400, 46 | 'body': json.dumps({'error': 'Invalid JSON or encoding in request body'}) 47 | } 48 | else: 49 | logger.info('Wrong request body') 50 | return { 51 | 'statusCode': 400, 52 | 'body': json.dumps({'error': 'Wrong request body'}) 53 | } 54 | 55 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 56 | ddb_table = dynamodb.Table(cell_management_table) 57 | 58 | if source == "cellManagement.tenantCreated": 59 | stack_name = "Cell-" + detail.get("CELL_ID") + "-Tenant-" + detail.get("TENANT_ID") 60 | stack_outputs = json.loads(detail.get("STACK_OUTPUTS")) 61 | cloudformation_outputs = stack_outputs[stack_name] 62 | 63 | tenent_update_response = ddb_table.update_item( 64 | Key={ 65 | 'PK': detail.get("CELL_ID")+"#"+detail.get("TENANT_ID") 66 | }, 67 | UpdateExpression="set current_status = :cs, cf_stack = :cfs, cf_metadata = :md", 68 | ExpressionAttributeValues={ 69 | ':cs': 'available', 70 | ':cfs': stack_name, 71 | ':md': json.dumps(cloudformation_outputs) 72 | }, 73 | ReturnValues="UPDATED_NEW" 74 | ) 75 | return { 76 | 'statusCode': 200, 77 | 'body': json.dumps({'status': 'creating'}) 78 | } 79 | else: 80 | stack_name = "Cell-" + detail.get("CELL_ID") + "-Tenant-" + detail.get("TENANT_ID") 81 | 82 | tenent_update_response = ddb_table.update_item( 83 | Key={ 84 | 'PK': detail.get("CELL_ID")+"#"+detail.get("TENANT_ID") 85 | }, 86 | UpdateExpression="set current_status = :cs, cf_stack = :cfs", 87 | ExpressionAttributeValues={ 88 | ':cs': 'failed', 89 | ':cfs': stack_name 90 | }, 91 | ReturnValues="UPDATED_NEW" 92 | ) 93 | 94 | try: 95 | ddb_response = ddb_table.get_item( 96 | Key={ 97 | 'PK': detail.get("CELL_ID") 98 | } 99 | ) 100 | except Exception as e: 101 | logger.error(f"Error retrieving cell status: {str(e)}") 102 | return { 103 | 'statusCode': 500, 104 | 'body': json.dumps('Error retrieving cell status') 105 | } 106 | 107 | if 'Item' in ddb_response: 108 | item = ddb_response['Item'] 109 | cell_utilization = int(item.get('cell_utilization','0')) 110 | 111 | cell_update_response = ddb_table.update_item( 112 | Key={ 113 | 'PK': detail.get("CELL_ID") 114 | }, 115 | UpdateExpression="set cell_utilization = :cu", 116 | ExpressionAttributeValues={ 117 | ':cu': cell_utilization - 1 118 | }, 119 | ReturnValues="UPDATED_NEW" 120 | ) 121 | return { 122 | 'statusCode': 200, 123 | 'body': json.dumps({'status': 'tenant error processed'}) 124 | } -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/ActivateTenant/activateTenant.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import boto3 4 | import logging 5 | from botocore.exceptions import ClientError 6 | from botocore.config import Config 7 | 8 | logger = logging.getLogger() 9 | logger.setLevel(logging.INFO) 10 | 11 | DYNAMO_CELL_MANAGEMENT_TABLE = os.environ.get('CELL_MANAGEMENT_TABLE') 12 | CELL_ROUTER_KVS_ARN = os.environ.get('CELL_ROUTER_KVS_ARN') 13 | 14 | kvsClient = boto3.session.Session().client( 15 | 'cloudfront-keyvaluestore', 16 | region_name='us-east-1', 17 | config=Config(tcp_keepalive=True,retries={'mode': 'adaptive'},signature_version='v4') 18 | ) 19 | 20 | dynamodb = boto3.resource('dynamodb') 21 | ddb_table = dynamodb.Table(DYNAMO_CELL_MANAGEMENT_TABLE) 22 | 23 | def write_cell_routing_entry(tenantId, url): 24 | try: 25 | logger.debug('writing config for tenant to KVS') 26 | 27 | # Get the current etag 28 | describe_response = kvsClient.describe_key_value_store( 29 | KvsARN=CELL_ROUTER_KVS_ARN 30 | ) 31 | 32 | logger.info(f"describe_response: {describe_response}") 33 | 34 | # Write the new routing information to KVS 35 | put_response = kvsClient.put_key( 36 | Key=tenantId, 37 | Value=url, 38 | KvsARN=CELL_ROUTER_KVS_ARN, 39 | IfMatch=describe_response.get('ETag') 40 | ) 41 | 42 | except ClientError as e: 43 | logger.error(f'Error persisting config object: {e}') 44 | except json.JSONDecodeError as e: 45 | logger.error(f'Error parsing config object: {e}') 46 | except Exception as e: 47 | logger.error(f'Unexpected Error persisting or parsing config object: {e}') 48 | 49 | def retrieve_cell_details(cell_id): 50 | 51 | try: 52 | response = ddb_table.get_item( 53 | Key={ 54 | 'PK': cell_id 55 | } 56 | ) 57 | logger.debug(f"Response from DynamoDB: {response}") 58 | except Exception as e: 59 | logger.error(f"Error retrieving tenant status: {str(e)}") 60 | return { 61 | 'statusCode': 500, 62 | 'body': json.dumps('Error retrieving tenant status') 63 | } 64 | 65 | if 'Item' in response: 66 | item = response['Item'] 67 | return item 68 | else: 69 | return { 70 | 'statusCode': 404, 71 | 'body': json.dumps(f'Cell not found: {cell_id}') 72 | } 73 | 74 | def retrieve_tenant_details(cell_id,tenant_id): 75 | 76 | try: 77 | response = ddb_table.get_item( 78 | Key={ 79 | 'PK': cell_id+"#"+tenant_id 80 | } 81 | ) 82 | logger.debug(f"Response from DynamoDB: {response}") 83 | except Exception as e: 84 | logger.error(f"Error retrieving tenant status: {str(e)}") 85 | return { 86 | 'statusCode': 500, 87 | 'body': json.dumps('Error retrieving tenant status') 88 | } 89 | 90 | if 'Item' in response: 91 | item = response['Item'] 92 | return item 93 | else: 94 | return { 95 | 'statusCode': 404, 96 | 'body': json.dumps(f'Tenant ${tenant_id} not found in Cell {cell_id}') 97 | } 98 | 99 | def handler(event, context): 100 | 101 | # Log the entire event 102 | logger.debug('Received event: %s', event) 103 | 104 | # Extract the request body 105 | body = event.get('body') 106 | if body: 107 | try: 108 | data = json.loads(body) 109 | tenant_id = data.get('TenantId') 110 | logger.debug('Tenant ID: %s', tenant_id) 111 | cell_id = data.get('CellId') 112 | logger.debug('Cell ID: %s', cell_id) 113 | 114 | cell_details = retrieve_cell_details(cell_id=cell_id) 115 | tenant_details = retrieve_tenant_details(cell_id=cell_id,tenant_id=tenant_id) 116 | 117 | logger.debug("retrieved cell details: %s", cell_details) 118 | logger.debug("retrieved tenant details: %s", tenant_details) 119 | 120 | if cell_details.get('current_status') == "available" and tenant_details.get('current_status') == "available": 121 | logger.debug('updated config being written: %s -> %s',tenant_id, cell_details.get('cell_url')) 122 | write_cell_routing_entry(tenant_id, cell_details.get('cell_url')) 123 | logger.debug('config map successfully written to KVS') 124 | else: 125 | return { 126 | 'statusCode': 400, 127 | 'body': json.dumps({'error': 'Cell or Tenant is not available'}), 128 | } 129 | except json.JSONDecodeError: 130 | logger.error('Invalid JSON in request body') 131 | return { 132 | 'statusCode': 400, 133 | 'body': json.dumps({'error': 'Invalid JSON in request body'}) 134 | } 135 | else: 136 | logger.error('No request body') 137 | return { 138 | 'statusCode': 400, 139 | 'body': json.dumps({'error': 'Invalid JSON in request body'}) 140 | } 141 | 142 | # Process the request and generate a response 143 | response = { 144 | 'statusCode': 200, 145 | 'body': json.dumps({'TenantId': tenant_id, 'CellId': cell_id, 'Status': "ACTIVE"}) 146 | } 147 | 148 | # Log the response 149 | logger.info('Response: %s', response) 150 | return response -------------------------------------------------------------------------------- /lib/application-plane/cell-app-plane/src/product.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify, Response 2 | import boto3 3 | from botocore.exceptions import NoCredentialsError, PartialCredentialsError, ClientError 4 | import os 5 | import psycopg 6 | from models.product_models import Product 7 | import json 8 | import logging 9 | from jose import jwk, jwt 10 | from jose.utils import base64url_decode 11 | import string 12 | import random 13 | 14 | #A function that created a 1MB dummy log entry. This log entry will be used to simulate the failure of logging to Amazon CloudWatch. The 1MB will fill up the logging buffer very fast. 15 | def generate_large_log_entry(size_mb=1): 16 | """Generate a string of approximately size_mb megabytes""" 17 | # 1 MB = 1048576 bytes 18 | # Using ASCII letters for the content 19 | chars = string.ascii_letters + string.digits 20 | return ''.join(random.choice(chars) for _ in range(1048576)) 21 | 22 | secrets_manager = boto3.client('secretsmanager', region_name=os.environ['AWS_REGION']) 23 | 24 | app = Flask(__name__) 25 | app.logger.setLevel(logging.DEBUG) 26 | 27 | def get_tenant_id(request): 28 | bearer_token = request.headers.get('Authorization') 29 | if not bearer_token: 30 | return None 31 | token = bearer_token.split(" ")[1] 32 | # get the tenant id from the token 33 | tenant_id = jwt.get_unverified_claims(token)['custom:tenantId'] 34 | return tenant_id 35 | 36 | def get_tenant_secret(tenant_id): 37 | response = secrets_manager.get_secret_value(SecretId=tenant_id+'Credentials') 38 | 39 | secret_value = response['SecretString'] 40 | #convert string to json 41 | secret_value = json.loads(secret_value) 42 | 43 | password = secret_value["password"] 44 | host = secret_value["host"] 45 | port = secret_value["port"] 46 | username = secret_value["username"] 47 | print(password, host, port) 48 | return password, host, port, username 49 | 50 | def tenant_connection(tenant_id): 51 | password, host, port, username = get_tenant_secret(tenant_id) 52 | 53 | connection = psycopg.connect(dbname=tenant_id, 54 | host=host, 55 | port=port, 56 | user=username, 57 | password=password, 58 | autocommit=True) 59 | return connection 60 | 61 | @app.route('/') 62 | def home(): 63 | return "Welcome to ProductService!!" 64 | 65 | @app.route('/health', methods=['GET']) 66 | def health_check(): 67 | health_status = { 68 | 'status': 'UP', 69 | 'details': 'Application is running smoothly!!' 70 | } 71 | return jsonify(health_status) 72 | 73 | 74 | @app.route('/product', methods=['POST']) 75 | def create_product(): 76 | connection = None 77 | try: 78 | # Generate and log 1MB entry 79 | large_log = generate_large_log_entry() 80 | app.logger.info(f"Large log entry: {large_log}") 81 | 82 | app.logger.info (request.headers) 83 | app.logger.info ("This is a new deployment of the application") 84 | #tenant_id = request.headers.get('tenantId') 85 | tenant_id = get_tenant_id(request) 86 | app.logger.info(tenant_id) 87 | if not tenant_id: 88 | return jsonify({"error": "tenantId header is required"}), 400 89 | 90 | connection = tenant_connection(tenant_id) 91 | product_info = request.get_json() 92 | product = Product(**product_info, tenantId=tenant_id) 93 | connection.execute("INSERT INTO app.products (product_id, product_name, product_description, product_price, tenant_id) VALUES (%s, %s, %s, %s, %s)", (product.productId, product.productName, product.productDescription, product.productPrice, product.tenantId)) 94 | 95 | except Exception as e: 96 | return jsonify({"error": str(e)}), 500 97 | finally: 98 | if connection: 99 | connection.close() 100 | 101 | 102 | return jsonify({"message": "product created"}), 200 103 | 104 | 105 | @app.route('/product', methods=['GET']) 106 | def get_products(): 107 | connection = None 108 | try: 109 | app.logger.info (request.headers) 110 | #tenant_id = request.headers.get('tenantId') 111 | tenant_id = get_tenant_id(request) 112 | app.logger.info (tenant_id) 113 | 114 | if not tenant_id: 115 | return jsonify({"error": "tenantId header is required"}), 400 116 | 117 | connection = tenant_connection(tenant_id) 118 | cur = connection.execute("SELECT product_id, product_name, product_description, product_price, tenant_id FROM app.products WHERE tenant_id = '{0}'".format(tenant_id)) 119 | results = cur.fetchall() 120 | app.logger.info(results) 121 | products=[] 122 | for record in results: 123 | product = Product(record[0], record[1], record[2], record[3], record[4]) 124 | products.append(product.__dict__) 125 | 126 | app.logger.info (products) 127 | 128 | return jsonify(products), 200 129 | 130 | except Exception as e: 131 | return jsonify({"error while getting product": str(e)}), 500 132 | finally: 133 | if connection: 134 | connection.close() 135 | 136 | return jsonify(product.__dict__), 200 137 | 138 | if __name__ == "__main__": 139 | app.run("0.0.0.0", port=80, debug=False) -------------------------------------------------------------------------------- /lib/application-plane/common-components/observability/observability-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps, RemovalPolicy, CfnOutput, Duration, Tags } from 'aws-cdk-lib'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | 4 | import { CdkNagUtils } from './src/utils/cdk-nag-utils' 5 | import { Construct } from 'constructs'; 6 | import { Dashboard, GraphWidget, MathExpression, Row, GraphWidgetView, TextWidget } from "aws-cdk-lib/aws-cloudwatch"; 7 | import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; 8 | 9 | export interface CommonObservabilityProps extends StackProps { 10 | readonly distributionId: string 11 | } 12 | 13 | export class CommonObservability extends Stack { 14 | 15 | readonly aggregateHttp5xxAlarmName: string ; 16 | 17 | constructor(scope: Construct, id: string, props: CommonObservabilityProps) { 18 | super(scope, id, props); 19 | 20 | // Handle CDK nag suppressions. 21 | CdkNagUtils.suppressCDKNag(this); 22 | 23 | Tags.of(this).add('SaaSApplicationService', `Observability`); 24 | 25 | //TODO: Paste the code for the aggregated alarms, below this line 26 | 27 | const applicationPlaneHealthDashboard = new Dashboard(this, 'ApplicationPlaneHealthDashboard', { 28 | dashboardName: 'SaaS-App-Plane-Health-Dashboard', 29 | defaultInterval: Duration.minutes(30) 30 | }); 31 | 32 | applicationPlaneHealthDashboard.addWidgets( 33 | new TextWidget({ 34 | markdown: `# Application Plane Health Dashboard`, 35 | height: 1, 36 | width: 24 37 | }), 38 | //TODO: Paste the code for aggregated metrics, below this line 39 | 40 | //TODO: Paste the code for the alarm widgets, below this line 41 | 42 | new Row( 43 | new GraphWidget({ 44 | title: 'Total API Requests By Cell', 45 | left: [ 46 | new MathExpression({ 47 | expression: 'SELECT COUNT(CellAPICount) FROM SaaSApplicationPlane GROUP BY cellId', 48 | label: 'Requests', 49 | }) 50 | ], 51 | leftYAxis: { 52 | label: 'Count', 53 | showUnits: false 54 | }, 55 | period: Duration.minutes(1), 56 | width: 8, 57 | height: 6, 58 | liveData: true, 59 | view: GraphWidgetView.TIME_SERIES 60 | }), 61 | new GraphWidget({ 62 | title: 'Average API Latency By Cell', 63 | left: [ 64 | new MathExpression({ 65 | expression: 'SELECT AVG(CellAPILatency) FROM SaaSApplicationPlane GROUP BY cellId', 66 | label: 'Latency', 67 | }) 68 | ], 69 | leftYAxis: { 70 | label: 'Milliseconds', 71 | showUnits: false 72 | }, 73 | period: Duration.minutes(1), 74 | width: 8, 75 | height: 6, 76 | liveData: true, 77 | view: GraphWidgetView.TIME_SERIES 78 | }) 79 | ), 80 | new Row( 81 | new GraphWidget({ 82 | title: 'Database ReadIOPS By Cell', 83 | left: [ 84 | new MathExpression({ 85 | expression: 'SELECT AVG(ReadIOPS) FROM "AWS/RDS" GROUP BY DBClusterIdentifier', 86 | label: 'ReadIOPS', 87 | }) 88 | ], 89 | leftYAxis: { 90 | label: 'Count/Second', 91 | showUnits: false 92 | }, 93 | period: Duration.minutes(5), 94 | width: 8, 95 | height: 6, 96 | liveData: true, 97 | view: GraphWidgetView.TIME_SERIES 98 | }), 99 | new GraphWidget({ 100 | title: 'Database WriteIOPS By Cell', 101 | left: [ 102 | new MathExpression({ 103 | expression: 'SELECT AVG(WriteIOPS) FROM "AWS/RDS" GROUP BY DBClusterIdentifier', 104 | label: 'WriteIOPS', 105 | }) 106 | ], 107 | leftYAxis: { 108 | label: 'Count/Second', 109 | showUnits: false 110 | }, 111 | period: Duration.minutes(5), 112 | width: 8, 113 | height: 6, 114 | liveData: true, 115 | view: GraphWidgetView.TIME_SERIES 116 | }), 117 | new GraphWidget({ 118 | title: 'Database CPU Utilization By Cell', 119 | left: [ 120 | new MathExpression({ 121 | expression: 'SELECT AVG(CPUUtilization) FROM "AWS/RDS" GROUP BY DBClusterIdentifier', 122 | label: 'CPUUtilization', 123 | }) 124 | ], 125 | leftYAxis: { 126 | label: 'Percent', 127 | showUnits: false 128 | }, 129 | period: Duration.minutes(5), 130 | width: 8, 131 | height: 6, 132 | liveData: true, 133 | view: GraphWidgetView.TIME_SERIES 134 | }), 135 | ), 136 | new Row( 137 | new GraphWidget({ 138 | title: 'ECS CPU Utilization By Cell', 139 | left: [ 140 | new MathExpression({ 141 | expression: 'SELECT AVG(CPUUtilization) FROM "AWS/ECS" GROUP BY ClusterName', 142 | label: 'CPU Utilization', 143 | }) 144 | ], 145 | leftYAxis: { 146 | label: 'Percent', 147 | showUnits: false 148 | }, 149 | period: Duration.minutes(5), 150 | width: 8, 151 | height: 6, 152 | liveData: true, 153 | view: GraphWidgetView.TIME_SERIES 154 | }), 155 | new GraphWidget({ 156 | title: 'ECS Memory Utilization By Cell', 157 | left: [ 158 | new MathExpression({ 159 | expression: 'SELECT AVG(MemoryUtilization) FROM "AWS/ECS" GROUP BY ClusterName', 160 | label: 'Memory Utilization', 161 | }) 162 | ], 163 | leftYAxis: { 164 | label: 'Percent', 165 | showUnits: false 166 | }, 167 | period: Duration.minutes(5), 168 | width: 8, 169 | height: 6, 170 | liveData: true, 171 | view: GraphWidgetView.TIME_SERIES 172 | }), 173 | new GraphWidget({ 174 | title: 'Tenant CPU Utilization', 175 | left: [ 176 | new MathExpression({ 177 | expression: 'SELECT AVG(CPUUtilization) FROM "AWS/ECS" GROUP BY ServiceName', 178 | label: 'CPUUtilization', 179 | }) 180 | ], 181 | leftYAxis: { 182 | label: 'Percent', 183 | showUnits: false 184 | }, 185 | period: Duration.minutes(5), 186 | width: 8, 187 | height: 6, 188 | liveData: true, 189 | view: GraphWidgetView.TIME_SERIES 190 | }), 191 | ) 192 | ) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /scripts/load-generator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Default values 4 | DEFAULT_DURATION=60 5 | 6 | # Color definitions 7 | RED='\033[0;31m' 8 | GREEN='\033[0;32m' 9 | YELLOW='\033[1;33m' 10 | BLUE='\033[1;34m' 11 | 12 | NC='\033[0m' # No Color 13 | 14 | # Initialize arrays 15 | declare -a CELL_IDS 16 | declare -a TENANT_IDS 17 | declare -a ID_TOKENS 18 | declare -a SUCCESS_COUNTS 19 | declare -a FAILED_4XX_COUNTS 20 | declare -a FAILED_5XX_COUNTS 21 | declare -a LAST_RESPONSES 22 | 23 | # Parse command line arguments 24 | while [[ $# -gt 0 ]]; do 25 | case $1 in 26 | --cell-ids) 27 | IFS=',' read -ra CELL_IDS <<< "$2" 28 | shift 2 29 | ;; 30 | --tenant-ids) 31 | IFS=',' read -ra TENANT_IDS <<< "$2" 32 | shift 2 33 | ;; 34 | --duration) 35 | DURATION="$2" 36 | shift 2 37 | ;; 38 | *) 39 | echo -e "${RED}Unknown parameter: $1${NC}" 40 | echo -e "${YELLOW}Usage: $0 --cell-ids --tenant-ids --duration ${NC}" 41 | exit 1 42 | ;; 43 | esac 44 | done 45 | 46 | # Validation checks 47 | if [ ${#CELL_IDS[@]} -eq 0 ]; then 48 | echo -e "${RED}Error: No cell IDs provided${NC}" 49 | exit 1 50 | fi 51 | 52 | if [ ${#TENANT_IDS[@]} -eq 0 ]; then 53 | echo -e "${RED}Error: No tenant IDs provided${NC}" 54 | exit 1 55 | fi 56 | 57 | if [ ${#CELL_IDS[@]} -ne ${#TENANT_IDS[@]} ]; then 58 | echo -e "${RED}Error: Number of cell IDs must match number of tenant IDs${NC}" 59 | exit 1 60 | fi 61 | 62 | # Set default values if not provided 63 | DURATION=${DURATION:-$DEFAULT_DURATION} 64 | 65 | # Function to draw table header 66 | draw_table_header() { 67 | echo -e "\n┌────────────┬────────────┬────────────┬────────────┬────────────┐" 68 | echo -e "│ ${BLUE}Cell ID${NC} │ ${BLUE}Tenant ID${NC} | ${GREEN}Successful${NC} │ ${YELLOW}4XX${NC} │ ${RED}5XX${NC} │" 69 | echo -e "├────────────┼────────────┼────────────┼────────────┼────────────┤" 70 | } 71 | 72 | # Function to draw table row 73 | draw_table_row() { 74 | local cell_id=$1 75 | local tenant_id=$2 76 | local i=$3 77 | local success=${SUCCESS_COUNTS[$i]} 78 | local failed_4xx=${FAILED_4XX_COUNTS[$i]} 79 | local failed_5xx=${FAILED_5XX_COUNTS[$i]} 80 | local last_response="${LAST_RESPONSES[$i]:-N/A}" 81 | 82 | # Truncate last response to fit in column 83 | last_response="${last_response:0:24}" 84 | 85 | printf "│ %-10s │ %-10s │ %10d │ %10d │ %10d │\n" \ 86 | "$cell_id" "$tenant_id" "$success" "$failed_4xx" "$failed_5xx" 87 | } 88 | 89 | # Function to draw table footer 90 | draw_table_footer() { 91 | echo -e "└────────────┴────────────┴────────────┴────────────┴────────────┘" 92 | } 93 | 94 | # Function to prepare login for a cell-tenant pair 95 | prepare_login() { 96 | local cell_id=$1 97 | local tenant_id=$2 98 | 99 | # Retrieve the USER_POOL_ID of the cell 100 | local user_pool_id=$(aws cloudformation describe-stacks --stack-name Cell-${cell_id} \ 101 | --query "Stacks[0].Outputs[?OutputKey=='CellUserPoolId'].OutputValue" | jq -r '.[0]') 102 | 103 | # Retrieve the USER_POOL_CLIENT_ID of the cell 104 | local user_pool_client_id=$(aws cloudformation describe-stacks --stack-name Cell-${cell_id} \ 105 | --query "Stacks[0].Outputs[?OutputKey=='CellAppClientId'].OutputValue" | jq -r '.[0]') 106 | 107 | # Set the password on Cognito 108 | aws cognito-idp admin-set-user-password \ 109 | --user-pool-id "${user_pool_id}" \ 110 | --username "tenantadmin-${tenant_id}" \ 111 | --password "tenat@AdminPass1" \ 112 | --permanent 113 | 114 | # Login and get token 115 | local id_token=$(aws cognito-idp initiate-auth \ 116 | --auth-flow USER_PASSWORD_AUTH \ 117 | --client-id "${user_pool_client_id}" \ 118 | --auth-parameters "USERNAME=tenantadmin-${tenant_id},PASSWORD=tenat@AdminPass1" \ 119 | --query 'AuthenticationResult' | jq -r '.IdToken') 120 | 121 | echo -e "${id_token}" 122 | } 123 | 124 | # Function to make the API call 125 | make_request() { 126 | local id_token=$1 127 | local tenant_id=$2 128 | local distribution_url=$3 129 | 130 | local timestamp=$(date +%s | tail -c 7) 131 | local random=$((RANDOM % 1000)) 132 | local product_id=$((10#$timestamp * 1000 + random)) 133 | 134 | local response=$(curl -s -w "\n%{http_code}" -X POST \ 135 | --url "https://${distribution_url}/product" \ 136 | -H "content-type: application/json" \ 137 | -H "Authorization: Bearer ${id_token}" \ 138 | -d "{\"productId\":\"${product_id}\",\"productName\":\"p${product_id}\",\"productDescription\":\"p${product_id}desc\",\"productPrice\":\"10\"}") 139 | 140 | local body=$(echo -e "$response" | sed '$d') 141 | local http_code=$(echo -e "$response" | tail -n1) 142 | echo -e "$http_code|$body" 143 | } 144 | 145 | # Display the configuration 146 | echo -e "${YELLOW}Running with configuration:${NC}" 147 | echo -e "${GREEN}Cell IDs: ${CELL_IDS[*]}${NC}" 148 | echo -e "${GREEN}Tenant IDs: ${TENANT_IDS[*]}${NC}" 149 | echo -e "${GREEN}Duration: ${DURATION} seconds${NC}" 150 | 151 | # Get distribution URL 152 | DISTRIBUTION_URL=$(aws cloudformation describe-stacks --stack-name CellRouter \ 153 | --query "Stacks[0].Outputs[?starts_with(OutputKey, 'DistributionUrl')].OutputValue" --output text) 154 | 155 | echo -e "${YELLOW}The cell router URL: ${DISTRIBUTION_URL}${NC}" 156 | 157 | 158 | 159 | # Prepare logins for all cell-tenant pairs 160 | for i in "${!CELL_IDS[@]}"; do 161 | cell_id="${CELL_IDS[$i]}" 162 | tenant_id="${TENANT_IDS[$i]}" 163 | echo -e "${YELLOW}Preparing login for cell:${NC} ${cell_id} ${YELLOW}and tenant:${NC} ${tenant_id}" 164 | ID_TOKENS[$i]=$(prepare_login "$cell_id" "$tenant_id") 165 | echo -e "${GREEN}Login prepared successfully.${NC}" 166 | index=$cell_id$tenant_id 167 | SUCCESS_COUNTS[$i]=0 168 | FAILED_4XX_COUNTS[$i]=0 169 | FAILED_5XX_COUNTS[$i]=0 170 | LAST_RESPONSES[$i]="N/A" 171 | done 172 | 173 | # Calculate end time 174 | END_TIME=$(($(date +%s) + DURATION)) 175 | 176 | # Draw initial rows 177 | clear 178 | echo -e "${Blue}Test Progress:${NC}" 179 | draw_table_header 180 | for i in "${!CELL_IDS[@]}"; do 181 | cell_id="${CELL_IDS[$i]}" 182 | tenant_id="${TENANT_IDS[$i]}" 183 | draw_table_row "$cell_id" "$tenant_id" 184 | done 185 | draw_table_footer 186 | 187 | 188 | # Main loop 189 | while [ $(date +%s) -lt $END_TIME ]; do 190 | for i in "${!CELL_IDS[@]}"; do 191 | cell_id="${CELL_IDS[$i]}" 192 | tenant_id="${TENANT_IDS[$i]}" 193 | response=$(make_request "${ID_TOKENS[$i]}" "$tenant_id" "$DISTRIBUTION_URL") 194 | http_code=$(echo -e "$response" | head -n1 | cut -c1-3) 195 | response_body=$(echo -e "$response" | tail -n1 | cut -d'|' -f2) 196 | 197 | # Update counters 198 | if [[ "$http_code" -ge 200 ]] && [[ "$http_code" -lt 300 ]]; then 199 | ((SUCCESS_COUNTS[$i]++)) 200 | elif [[ "$http_code" -ge 400 ]] && [[ "$http_code" -lt 500 ]]; then 201 | ((FAILED_4XX_COUNTS[$i]++)) 202 | elif [[ "$http_code" -ge 500 ]]; then 203 | ((FAILED_5XX_COUNTS[$i]++)) 204 | fi 205 | #Fetch the last response message for each cell and tenant combination. The code trims the first 60 characters only. 206 | LAST_RESPONSES[$i]=$(echo -e "${response_body}" | cut -c1-60) 207 | 208 | 209 | done 210 | clear 211 | echo -e "${BLUE}Test Progress:${NC}" 212 | draw_table_header 213 | for i in "${!CELL_IDS[@]}"; do 214 | cell_id="${CELL_IDS[$i]}" 215 | tenant_id="${TENANT_IDS[$i]}" 216 | draw_table_row "$cell_id" "$tenant_id" "$i" 217 | done 218 | draw_table_footer 219 | 220 | #Add the last response message for each entry. This is only valid when the --debug flag is passed. 221 | for i in "${!CELL_IDS[@]}"; do 222 | cell_id="${CELL_IDS[$i]}" 223 | tenant_id="${TENANT_IDS[$i]}" 224 | echo -e "Last Response for Cell ID: ${cell_id} and Tenant ID: ${tenant_id}: ${LAST_RESPONSES[$i]}" 225 | done 226 | 227 | done 228 | -------------------------------------------------------------------------------- /lib/saas-management/cell-management-system/src/lambdas/AssignTenantToCell/assignTenantToCell.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import json 4 | import boto3 5 | import string 6 | import random 7 | import re 8 | 9 | # Initialize clients 10 | eventbridge_client = boto3.client('events') 11 | dynamodb = boto3.resource('dynamodb') 12 | ssm_client = boto3.client('ssm') 13 | 14 | logger = logging.getLogger() 15 | logger.setLevel(logging.INFO) 16 | 17 | 18 | def handler(event, context): 19 | # Log the entire event 20 | logger.info('Received event: %s', event) 21 | 22 | # Extract the request body 23 | body = event.get('body') 24 | if body: 25 | try: 26 | data = json.loads(body) 27 | logger.info('Request body: %s', data) 28 | 29 | # Get parameters from the request body 30 | cell_id = data.get('CellId') 31 | tenant_name = data.get('TenantName') 32 | tenant_tier = data.get('TenantTier') 33 | tenant_email = data.get('TenantEmail') 34 | 35 | email_regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' 36 | if(re.fullmatch(email_regex, tenant_email) is None): 37 | logger.error('Invalid email address') 38 | return { 39 | 'statusCode': 400, 40 | 'body': json.dumps({'error': 'Invalid email address'}) 41 | } 42 | except (json.JSONDecodeError, UnicodeDecodeError): 43 | logger.error('Invalid JSON or encoding in request body') 44 | return { 45 | 'statusCode': 400, 46 | 'body': json.dumps({'error': 'Invalid JSON or encoding in request body'}) 47 | } 48 | else: 49 | logger.info('Wrong request body') 50 | return { 51 | 'statusCode': 400, 52 | 'body': json.dumps({'error': 'Wrong request body'}) 53 | } 54 | 55 | cell_information = retrieve_cell_information(cell_id=cell_id) 56 | cell_status = check_cell_status(cell_information) 57 | if cell_status.get('statusCode') == 200: 58 | 59 | # Generate tenant ID 60 | generated_tenant_id_prefix = random.SystemRandom().choice(string.ascii_lowercase) 61 | generated_tenant_id = generated_tenant_id_prefix + "".join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(8)) 62 | 63 | # Get next available listener priority for this cell 64 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 65 | ddb_table = dynamodb.Table(cell_management_table) 66 | priority_response = ddb_table.update_item( 67 | Key={ 68 | 'PK': cell_id 69 | }, 70 | UpdateExpression='ADD tenant_priority_counter :inc', 71 | ExpressionAttributeValues={ 72 | ':inc': 10 73 | }, 74 | ReturnValues='UPDATED_NEW' 75 | ) 76 | new_tenant_listener_priority = str(int(priority_response['Attributes']['tenant_priority_counter'])) 77 | 78 | image_version_param = os.environ.get('IMAGE_VER_SSM_PARAM_NAME') 79 | # Get Latest Product Container Tag 80 | image_version = ssm_client.get_parameter(Name=image_version_param) 81 | product_image_version = image_version['Parameter']['Value'] 82 | 83 | response = create_tenant(cell_id=cell_id, tenant_id=generated_tenant_id, tenant_name=tenant_name, 84 | tenant_tier=tenant_tier, tenant_email=tenant_email, tenant_listener_priority=new_tenant_listener_priority, 85 | product_image_version=product_image_version, cell_size=cell_information.get('cell_size')) 86 | # Log the response 87 | logger.info('Response: %s', response) 88 | return response 89 | else: 90 | # Log the response 91 | logger.info('Response: %s', cell_status) 92 | return cell_status 93 | 94 | 95 | def retrieve_cell_information(cell_id): 96 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 97 | ddb_table = dynamodb.Table(cell_management_table) 98 | try: 99 | response = ddb_table.get_item( 100 | Key={ 101 | 'PK': cell_id 102 | } 103 | ) 104 | except Exception as e: 105 | logger.error(f"Error retrieving tenant information: {str(e)}") 106 | return { 107 | 'statusCode': 500, 108 | 'body': json.dumps('Error retrieving tenant information') 109 | } 110 | 111 | if 'Item' in response: 112 | item = response['Item'] 113 | return item 114 | else: 115 | return { 116 | 'statusCode': 404, 117 | 'body': json.dumps(f'Cell not found: {cell_id}') 118 | } 119 | 120 | 121 | def check_cell_status(cell_information): 122 | 123 | cell_status = cell_information.get('current_status') 124 | cell_utilization = cell_information.get('cell_utilization') 125 | cell_size = cell_information.get('cell_max_capacity') 126 | 127 | if cell_status == 'available' and int(cell_utilization) < int(cell_size): 128 | return { 129 | 'statusCode': 200, 130 | 'body': json.dumps(f'Cell has availability of {int(cell_size) - int(cell_utilization)}') 131 | } 132 | else: 133 | return { 134 | 'statusCode': 503, 135 | 'body': json.dumps(f'Cell is currently unavailable or at full capacity') 136 | } 137 | 138 | 139 | def create_tenant(cell_id, tenant_name, tenant_id, tenant_tier, tenant_email, tenant_listener_priority, product_image_version, cell_size): 140 | 141 | # Send a message to EventBridge 142 | cell_management_bus = os.environ.get('CELL_MANAGEMENT_BUS') 143 | eventbridge_response = eventbridge_client.put_events( 144 | Entries=[ 145 | { 146 | 'Source': 'cellManagement.createTenant', 147 | 'DetailType': 'TenantData', 148 | 'Detail': json.dumps({ 149 | 'event_type': 'create_tenant', 150 | 'cell_id': cell_id, 151 | 'cell_size': cell_size, 152 | 'tenant_name': tenant_name, 153 | 'tenant_id': tenant_id, 154 | 'tenant_tier': tenant_tier, 155 | 'tenant_email': tenant_email, 156 | 'tenant_listener_priority': tenant_listener_priority, 157 | 'product_image_version': product_image_version 158 | }), 159 | 'EventBusName': cell_management_bus 160 | } 161 | ] 162 | ) 163 | 164 | # Check if the event was sent successfully 165 | if eventbridge_response['FailedEntryCount'] == 0: 166 | # Store the metadata in DynamoDB 167 | cell_management_table = os.environ.get('CELL_MANAGEMENT_TABLE') 168 | ddb_table = dynamodb.Table(cell_management_table) 169 | ddb_table.put_item( 170 | Item={ 171 | 'PK': cell_id+"#"+tenant_id, 172 | 'cell_id': cell_id, 173 | 'cell_size': cell_size, 174 | 'tenant_id': tenant_id, 175 | 'tenant_name': tenant_name, 176 | 'tenant_tier': tenant_tier, 177 | 'tenant_email': tenant_email, 178 | 'tenant_listener_priority': tenant_listener_priority, 179 | 'product_image_version': product_image_version, 180 | 'current_status': 'creating', 181 | } 182 | ) 183 | 184 | logger.info(f'Added tenant metadata to DynamoDB') 185 | 186 | ddb_table.update_item( 187 | Key={ 188 | 'PK': cell_id 189 | }, 190 | UpdateExpression='SET cell_utilization = cell_utilization + :inc', 191 | ExpressionAttributeValues={ 192 | ':inc': 1 193 | }, 194 | ReturnValues='UPDATED_NEW' 195 | ) 196 | logger.info(f'Cell utilization updated on DynamoDB') 197 | return { 198 | 'statusCode': 200, 199 | 'body': json.dumps({'CellId': cell_id,'TenantId': tenant_id, 'Status': 'creating'}) 200 | } 201 | else: 202 | logger.error(f"Failed to send event to EventBridge") 203 | return { 204 | 'statusCode': 500, 205 | 'body': json.dumps('Failed to send event to EventBridge') 206 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode,node 2 | # Edit at https://www.gitignore.io/?templates=osx,linux,python,windows,pycharm,visualstudiocode,node 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### Node ### 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | lerna-debug.log* 27 | 28 | # Diagnostic reports (https://nodejs.org/api/report.html) 29 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 30 | 31 | # Runtime data 32 | pids 33 | *.pid 34 | *.seed 35 | *.pid.lock 36 | 37 | # Directory for instrumented libs generated by jscoverage/JSCover 38 | lib-cov 39 | 40 | # Coverage directory used by tools like istanbul 41 | coverage 42 | *.lcov 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # Bower dependency directory (https://bower.io/) 51 | bower_components 52 | 53 | # node-waf configuration 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | build/Release 58 | 59 | # Dependency directories 60 | node_modules/ 61 | jspm_packages/ 62 | 63 | # TypeScript v1 declaration files 64 | typings/ 65 | 66 | # TypeScript cache 67 | *.tsbuildinfo 68 | 69 | # Optional npm cache directory 70 | .npm 71 | 72 | # Optional eslint cache 73 | .eslintcache 74 | 75 | # Optional REPL history 76 | .node_repl_history 77 | 78 | # Output of 'npm pack' 79 | *.tgz 80 | 81 | # Yarn Integrity file 82 | .yarn-integrity 83 | 84 | # dotenv environment variables file 85 | .env 86 | .env.test 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | 91 | # next.js build output 92 | .next 93 | 94 | # nuxt.js build output 95 | .nuxt 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | ### OSX ### 110 | # General 111 | .DS_Store 112 | .AppleDouble 113 | .LSOverride 114 | 115 | # Icon must end with two \r 116 | Icon 117 | 118 | # Thumbnails 119 | ._* 120 | 121 | # Files that might appear in the root of a volume 122 | .DocumentRevisions-V100 123 | .fseventsd 124 | .Spotlight-V100 125 | .TemporaryItems 126 | .Trashes 127 | .VolumeIcon.icns 128 | .com.apple.timemachine.donotpresent 129 | 130 | # Directories potentially created on remote AFP share 131 | .AppleDB 132 | .AppleDesktop 133 | Network Trash Folder 134 | Temporary Items 135 | .apdisk 136 | 137 | ### PyCharm ### 138 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 139 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 140 | 141 | # User-specific stuff 142 | .idea/**/workspace.xml 143 | .idea/**/tasks.xml 144 | .idea/**/usage.statistics.xml 145 | .idea/**/dictionaries 146 | .idea/**/shelf 147 | 148 | # Generated files 149 | .idea/**/contentModel.xml 150 | 151 | # Sensitive or high-churn files 152 | .idea/**/dataSources/ 153 | .idea/**/dataSources.ids 154 | .idea/**/dataSources.local.xml 155 | .idea/**/sqlDataSources.xml 156 | .idea/**/dynamic.xml 157 | .idea/**/uiDesigner.xml 158 | .idea/**/dbnavigator.xml 159 | 160 | # Gradle 161 | .idea/**/gradle.xml 162 | .idea/**/libraries 163 | 164 | # Gradle and Maven with auto-import 165 | # When using Gradle or Maven with auto-import, you should exclude module files, 166 | # since they will be recreated, and may cause churn. Uncomment if using 167 | # auto-import. 168 | .idea/*.xml 169 | .idea/*.iml 170 | .idea 171 | # .idea/modules 172 | # *.iml 173 | # *.ipr 174 | 175 | # CMake 176 | cmake-build-*/ 177 | 178 | # Mongo Explorer plugin 179 | .idea/**/mongoSettings.xml 180 | 181 | # File-based project format 182 | *.iws 183 | 184 | # IntelliJ 185 | out/ 186 | 187 | # mpeltonen/sbt-idea plugin 188 | .idea_modules/ 189 | 190 | # JIRA plugin 191 | atlassian-ide-plugin.xml 192 | 193 | # Cursive Clojure plugin 194 | .idea/replstate.xml 195 | 196 | # Crashlytics plugin (for Android Studio and IntelliJ) 197 | com_crashlytics_export_strings.xml 198 | crashlytics.properties 199 | crashlytics-build.properties 200 | fabric.properties 201 | 202 | # Editor-based Rest Client 203 | .idea/httpRequests 204 | 205 | # Android studio 3.1+ serialized cache file 206 | .idea/caches/build_file_checksums.ser 207 | 208 | ### PyCharm Patch ### 209 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 210 | 211 | # *.iml 212 | # modules.xml 213 | # .idea/misc.xml 214 | # *.ipr 215 | 216 | # Sonarlint plugin 217 | .idea/sonarlint 218 | 219 | ### Python ### 220 | # Byte-compiled / optimized / DLL files 221 | __pycache__/ 222 | *.py[cod] 223 | *$py.class 224 | 225 | # C extensions 226 | *.so 227 | 228 | # Distribution / packaging 229 | .Python 230 | build/ 231 | develop-eggs/ 232 | dist/ 233 | downloads/ 234 | eggs/ 235 | .eggs/ 236 | lib64/ 237 | parts/ 238 | sdist/ 239 | var/ 240 | wheels/ 241 | pip-wheel-metadata/ 242 | share/python-wheels/ 243 | *.egg-info/ 244 | .installed.cfg 245 | *.egg 246 | MANIFEST 247 | 248 | # PyInstaller 249 | # Usually these files are written by a python script from a template 250 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 251 | *.manifest 252 | *.spec 253 | 254 | # Installer logs 255 | pip-log.txt 256 | pip-delete-this-directory.txt 257 | 258 | # Unit test / coverage reports 259 | htmlcov/ 260 | .tox/ 261 | .nox/ 262 | .coverage 263 | .coverage.* 264 | nosetests.xml 265 | coverage.xml 266 | *.cover 267 | .hypothesis/ 268 | .pytest_cache/ 269 | 270 | # Translations 271 | *.mo 272 | *.pot 273 | 274 | # Django stuff: 275 | local_settings.py 276 | db.sqlite3 277 | db.sqlite3-journal 278 | 279 | # Flask stuff: 280 | instance/ 281 | .webassets-cache 282 | 283 | # Scrapy stuff: 284 | .scrapy 285 | 286 | # Sphinx documentation 287 | docs/_build/ 288 | 289 | # PyBuilder 290 | target/ 291 | 292 | # Jupyter Notebook 293 | .ipynb_checkpoints 294 | 295 | # IPython 296 | profile_default/ 297 | ipython_config.py 298 | 299 | # pyenv 300 | .python-version 301 | 302 | # pipenv 303 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 304 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 305 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 306 | # install all needed dependencies. 307 | #Pipfile.lock 308 | 309 | # celery beat schedule file 310 | celerybeat-schedule 311 | 312 | # SageMath parsed files 313 | *.sage.py 314 | 315 | # Environments 316 | .venv 317 | env/ 318 | venv/ 319 | ENV/ 320 | env.bak/ 321 | venv.bak/ 322 | 323 | # Spyder project settings 324 | .spyderproject 325 | .spyproject 326 | 327 | # Rope project settings 328 | .ropeproject 329 | 330 | # mkdocs documentation 331 | /site 332 | 333 | # mypy 334 | .mypy_cache/ 335 | .dmypy.json 336 | dmypy.json 337 | 338 | # Pyre type checker 339 | .pyre/ 340 | 341 | ### VisualStudioCode ### 342 | .vscode 343 | 344 | ### VisualStudioCode Patch ### 345 | # Ignore all local history of files 346 | .history 347 | 348 | ### Windows ### 349 | # Windows thumbnail cache files 350 | Thumbs.db 351 | Thumbs.db:encryptable 352 | ehthumbs.db 353 | ehthumbs_vista.db 354 | 355 | # Dump file 356 | *.stackdump 357 | 358 | # Folder config file 359 | [Dd]esktop.ini 360 | 361 | # Recycle Bin used on file shares 362 | $RECYCLE.BIN/ 363 | 364 | # Windows Installer files 365 | *.cab 366 | *.msi 367 | *.msix 368 | *.msm 369 | *.msp 370 | 371 | # Windows shortcuts 372 | *.lnk 373 | 374 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode,node 375 | 376 | ### CDK-specific ignores ### 377 | *.swp 378 | package-lock.json 379 | yarn.lock 380 | .cdk.staging 381 | cdk.out 382 | *.d.ts 383 | *.js 384 | lib/application-plane/cell-app-plane/cdk/stack_outputs.json 385 | lib/application-plane/cell-app-plane/cdk/tenant_stack_outputs.json 386 | lib/application-plane/cell-app-plane.zip 387 | lib/application-plane/cell-app-plane/scripts/next_image_version.txt 388 | lib/application-plane/cell-app-plane/scripts/next_listener_rule_priority_base.txt 389 | cell_id.txt 390 | tenant_id.txt 391 | out.txt 392 | 393 | !lib/application-plane/common-components/cell-router/src/functions/*.js -------------------------------------------------------------------------------- /lib/saas-management/cell-provisioning-system/src/lambdas/PrepDeploy/lambda-prepare-deploy.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import os 5 | import boto3 6 | import simplejson as json 7 | import zipfile 8 | import traceback 9 | 10 | print('Loading function') 11 | s3 = boto3.client('s3') 12 | code_pipeline = boto3.client('codepipeline') 13 | dynamodb = boto3.resource('dynamodb') 14 | cell_management_table_name = dynamodb.Table(os.environ['CELL_MANAGEMENT_TABLE_NAME']) 15 | 16 | def find_artifact(artifacts, name): 17 | """Finds the artifact 'name' among the 'artifacts' 18 | 19 | Args: 20 | artifacts: The list of artifacts available to the function 21 | name: The artifact we wish to use 22 | Returns: 23 | The artifact dictionary found 24 | Raises: 25 | Exception: If no matching artifact is found 26 | 27 | """ 28 | for artifact in artifacts: 29 | if artifact['name'] == name: 30 | return artifact 31 | 32 | raise Exception('Input artifact named "{0}" not found in event'.format(name)) 33 | 34 | 35 | def put_job_success(job, message): 36 | """Notify CodePipeline of a successful job 37 | 38 | Args: 39 | job: The CodePipeline job ID 40 | message: A message to be logged relating to the job status 41 | 42 | Raises: 43 | Exception: Any exception thrown by .put_job_success_result() 44 | 45 | """ 46 | print('Putting job success') 47 | print(message) 48 | code_pipeline.put_job_success_result(jobId=job) 49 | 50 | 51 | def put_job_failure(job, message): 52 | """Notify CodePipeline of a failed job 53 | 54 | Args: 55 | job: The CodePipeline job ID 56 | message: A message to be logged relating to the job status 57 | 58 | Raises: 59 | Exception: Any exception thrown by .put_job_failure_result() 60 | 61 | """ 62 | print('Putting job failure') 63 | print(message) 64 | code_pipeline.put_job_failure_result(jobId=job, failureDetails={'message': message, 'type': 'JobFailed'}) 65 | 66 | 67 | def get_user_params(job_data): 68 | """Decodes the JSON user parameters and validates the required properties. 69 | 70 | Args: 71 | job_data: The job data structure containing the UserParameters string which should be a valid JSON structure 72 | 73 | Returns: 74 | The JSON parameters decoded as a dictionary. 75 | 76 | Raises: 77 | Exception: The JSON can't be decoded or a property is missing. 78 | 79 | """ 80 | try: 81 | # Get the user parameters which contain the stack, artifact and file settings 82 | user_parameters = job_data['actionConfiguration']['configuration']['UserParameters'] 83 | decoded_parameters = json.loads(user_parameters) 84 | 85 | except Exception: 86 | # We're expecting the user parameters to be encoded as JSON 87 | # so we can pass multiple values. If the JSON can't be decoded 88 | # then fail the job with a helpful message. 89 | raise Exception('UserParameters could not be decoded as JSON') 90 | 91 | if 'artifact' not in decoded_parameters: 92 | # Validate that the artifact name is provided, otherwise fail the job 93 | # with a helpful message. 94 | raise Exception('Your UserParameters JSON must include the artifact name') 95 | 96 | if 's3_source_version_id' not in decoded_parameters: 97 | # Validate that the s3 source version ID is provided, otherwise fail the job 98 | # with a helpful message. 99 | raise Exception('Your UserParameters JSON must include the s3 source version ID') 100 | 101 | return decoded_parameters 102 | 103 | 104 | def add_parameter(params, parameter_key, parameter_value): 105 | parameter = {} 106 | parameter['ParameterKey'] = parameter_key 107 | parameter['ParameterValue'] = parameter_value 108 | params.append(parameter) 109 | 110 | 111 | def lambda_handler(event, context): 112 | """The Lambda function handler 113 | Args: 114 | event: The event passed by Lambda 115 | context: The context passed by Lambda 116 | 117 | """ 118 | try: 119 | print (event) 120 | # Extract the Job ID 121 | job_id = event['CodePipeline.job']['id'] 122 | print("job_id: ", job_id) 123 | 124 | # Extract the Job Data 125 | job_data = event['CodePipeline.job']['data'] 126 | print("job_data: ", job_data) 127 | 128 | # Extract the params 129 | params = get_user_params(job_data) 130 | commit_id = params['s3_source_version_id'] 131 | 132 | product_image_version = params['product_image_version'] 133 | 134 | # Get the list of artifacts passed to the function 135 | output_artifact = job_data['outputArtifacts'][0] 136 | 137 | # Get all the stacks for each tenant to be updated/created from tenant stack mapping table 138 | mappings = cell_management_table_name.scan() 139 | print('mappings success: ', mappings) 140 | output_bucket = output_artifact['location']['s3Location']['bucketName'] 141 | output_key = output_artifact['location']['s3Location']['objectKey'] 142 | cellStacks = [] 143 | tenantStacks = [] 144 | 145 | # Create array to pass to step function 146 | for mapping in mappings['Items']: 147 | if 'cell_id' not in mapping: 148 | stack = mapping['cf_stack'] 149 | cellId = mapping['PK'] 150 | waveNumber = mapping['wave_number'] 151 | cellSize = mapping['cell_size'] 152 | commitId = commit_id 153 | cellStacks.append( 154 | { 155 | "cellStackName": stack, 156 | "cellId": cellId, 157 | "waveNumber": int(waveNumber), 158 | "cellSize": cellSize, 159 | "commitId": commitId 160 | }) 161 | else: 162 | tenantStacks.append ( 163 | { 164 | "tenantStackName": mapping['cf_stack'], 165 | "tenantId": mapping['tenant_id'], 166 | "PK": mapping['PK'], 167 | "cellId": mapping['cell_id'], 168 | "cellSize": mapping['cell_size'], 169 | "tenantEmail": mapping['tenant_email'], 170 | "tenantListenerPriority": mapping['tenant_listener_priority'], 171 | "productImageVersion": product_image_version 172 | } 173 | ) 174 | 175 | 176 | 177 | for cells in cellStacks: 178 | tenantsInCell = [] 179 | for item in tenantStacks: 180 | if item["cellId"] == cells["cellId"]: 181 | tenantsInCell.append({ 182 | "tenantStackName": item["tenantStackName"], 183 | "tenantId": item["tenantId"], 184 | "PK": item["PK"], 185 | "cellId": item["cellId"], 186 | "cellSize": item["cellSize"], 187 | "tenantEmail": item["tenantEmail"], 188 | "tenantListenerPriority": str(item["tenantListenerPriority"]), 189 | "productImageVersion": item["productImageVersion"] 190 | }) 191 | cells["tenantsInCell"] = tenantsInCell 192 | 193 | except Exception as e: 194 | # If any other exceptions which we didn't expect are raised 195 | # then fail the job and log the exception message. 196 | print('Function failed due to exception.') 197 | print(e) 198 | traceback.print_exc() 199 | put_job_failure(job_id, 'Function exception: ' + str(e)) 200 | 201 | print(cellStacks) 202 | 203 | # write stacks variable to file 204 | with open('/tmp/output.json', 'w') as outfile: 205 | json.dump({"stacks": cellStacks}, outfile) 206 | 207 | # zip the file 208 | with zipfile.ZipFile('/tmp/output.json.zip', 'w') as zip: 209 | zip.write('/tmp/output.json', 'output.json') 210 | 211 | # upload the output to output_bucket in s3 212 | s3.upload_file('/tmp/output.json.zip', output_bucket, output_key) 213 | print('output.json.zip uploaded to s3') 214 | 215 | put_job_success(job_id, "Function complete.") 216 | print('Function complete.') 217 | return cellStacks 218 | -------------------------------------------------------------------------------- /scripts/inject-failure-cloudwatch-api.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Color definitions 4 | RED='\033[0;31m' 5 | GREEN='\033[1;32m' 6 | YELLOW='\033[1;33m' 7 | BLUE='\033[0;34m' 8 | NC='\033[0m' # No Color 9 | 10 | # Define CloudWatch logs domains to block with region placeholder 11 | declare -a DOMAINS 12 | DOMAINS[60]="logs.REGION.amazonaws.com" 13 | DOMAINS[80]="logs.REGION.api.aws" 14 | 15 | # Initialize arrays 16 | declare -a CELL_IDS 17 | 18 | # Store Rule numbers and NACL IDs for cleanup 19 | declare -a RULE_NUMBERS 20 | declare -A NACL_IDS 21 | 22 | # Parse command line arguments 23 | while [[ $# -gt 0 ]]; do 24 | case $1 in 25 | --cell-ids) 26 | IFS=',' read -ra CELL_IDS <<< "$2" 27 | shift 2 28 | ;; 29 | --duration) 30 | DURATION="$2" 31 | shift 2 32 | ;; 33 | --region) 34 | REGION="$2" 35 | shift 2 36 | ;; 37 | *) 38 | echo -e "${RED}Unknown parameter: $1${NC}" 39 | echo -e "${YELLOW}Usage: $0 --cell-ids --duration --region ${NC}" 40 | exit 1 41 | ;; 42 | esac 43 | done 44 | 45 | # Check if required parameters are provided 46 | if [ ${#CELL_IDS[@]} -eq 0 ] || [ -z "$DURATION" ] || [ -z "$REGION" ]; then 47 | echo -e "${RED}Missing required parameters${NC}" 48 | echo -e "${YELLOW}Usage: $0 --cell-ids --duration --region ${NC}" 49 | exit 1 50 | fi 51 | 52 | # Validate region 53 | if ! aws ec2 describe-regions --region-names "$REGION" &>/dev/null; then 54 | echo -e "${RED}Invalid AWS region: $REGION${NC}" 55 | exit 1 56 | fi 57 | 58 | # Export AWS_DEFAULT_REGION for AWS CLI commands 59 | export AWS_DEFAULT_REGION="$REGION" 60 | 61 | # Function to check if value exists in array 62 | contains_element() { 63 | local element="$1" 64 | local array=("${@:2}") 65 | for e in "${array[@]}"; do 66 | [[ "$e" == "$element" ]] && return 0 67 | done 68 | return 1 69 | } 70 | 71 | # Function to resolve DNS and get CIDR blocks 72 | get_cidr_blocks() { 73 | local domain=$1 74 | local subnets_array=() 75 | 76 | echo -e "${BLUE}Resolving $domain${NC}" >&2 77 | local ips=($(dig +short "$domain")) 78 | 79 | if [ ${#ips[@]} -eq 0 ]; then 80 | echo -e "${RED}Failed to resolve IPs for $domain${NC}" >&2 81 | return 1 82 | fi 83 | 84 | # Process each IP and store unique subnets 85 | for ip in "${ips[@]}"; do 86 | # Get everything up to the last dot, then append .0/24 87 | subnet="${ip%.*}.0/24" 88 | # Only add if not already in array 89 | if ! contains_element "$subnet" "${subnets_array[@]}"; then 90 | subnets_array+=("$subnet") 91 | fi 92 | done 93 | 94 | printf '%s\n' "${subnets_array[@]}" 95 | } 96 | 97 | # Function to add deny rules for all IPs 98 | add_deny_rules() { 99 | local nacl_id=$1 100 | local base_rule_number=$2 101 | local domain=$3 102 | local sub_rule=0 103 | 104 | echo -e "${BLUE}Adding deny rules for $domain${NC}" 105 | 106 | # Get all CIDR blocks for the domain 107 | local cidr_blocks=($(get_cidr_blocks "$domain")) 108 | if [ $? -ne 0 ]; then 109 | return 1 110 | fi 111 | 112 | for cidr_block in "${cidr_blocks[@]}"; do 113 | # Skip empty entries 114 | [ -z "$cidr_block" ] && continue 115 | 116 | local rule_number=$((base_rule_number + sub_rule)) 117 | echo -e "${BLUE}Adding rule $rule_number for CIDR: $cidr_block${NC}" 118 | 119 | aws ec2 create-network-acl-entry \ 120 | --network-acl-id "$nacl_id" \ 121 | --rule-number "$rule_number" \ 122 | --protocol -1 \ 123 | --rule-action deny \ 124 | --egress \ 125 | --cidr-block "$cidr_block" 126 | 127 | if [ $? -eq 0 ]; then 128 | echo -e "${GREEN}Added deny rule for $domain ($cidr_block)${NC}" 129 | RULE_NUMBERS["${rule_number}"]="$rule_number" 130 | ((sub_rule++)) 131 | else 132 | echo -e "${RED}Failed to add deny rule for $domain ($cidr_block)${NC}" 133 | fi 134 | done 135 | } 136 | 137 | revert_cell() { 138 | local cell_id=$1 139 | local nacl_id="${NACL_IDS[$cell_id]}" 140 | 141 | if [ -n "$nacl_id" ]; then 142 | echo -e "\n${YELLOW}Rolling back rules for Cell ${cell_id} on NACL ${nacl_id} ${NC}" 143 | for base_rule_number in "${!DOMAINS[@]}"; do 144 | domain="${DOMAINS[$base_rule_number]}" 145 | domain="${domain/REGION/$REGION}" 146 | delete_deny_rules "$nacl_id" "$base_rule_number" "$domain" 147 | done 148 | fi 149 | } 150 | 151 | delete_deny_rules() { 152 | local nacl_id=$1 153 | local base_rule_number=$2 154 | local domain=$3 155 | local sub_rule=0 156 | 157 | echo -e "${BLUE}Deleting deny rules for $nacl_id and $domain${NC}" 158 | 159 | # Get all CIDR blocks for the domain 160 | local cidr_blocks=($(get_cidr_blocks "$domain")) 161 | if [ $? -ne 0 ]; then 162 | return 1 163 | fi 164 | 165 | for cidr_block in "${cidr_blocks[@]}"; do 166 | # Skip empty entries 167 | [ -z "$cidr_block" ] && continue 168 | 169 | local rule_number=$((base_rule_number + sub_rule)) 170 | echo -e "${BLUE}Deleting rule $rule_number for CIDR: $cidr_block${NC}" 171 | 172 | aws ec2 delete-network-acl-entry \ 173 | --network-acl-id "$nacl_id" \ 174 | --rule-number "$rule_number" \ 175 | --egress 176 | 177 | if [ $? -eq 0 ]; then 178 | echo -e "${GREEN}Deleted deny rule for $domain ($cidr_block)${NC}" 179 | #RULE_NUMBERS["${rule_number}"]="$rule_number" 180 | ((sub_rule++)) 181 | else 182 | echo -e "${RED}Failed to delete deny rule for $domain ($cidr_block)${NC}" 183 | fi 184 | done 185 | } 186 | 187 | # Function to process a single cell 188 | process_cell() { 189 | local cell_id=$1 190 | local vpc_id 191 | local nacl_id 192 | 193 | echo -e "\n${YELLOW}Processing Cell: ${cell_id} in region ${REGION}${NC}" 194 | 195 | # Retrieve VPC ID that starts with the cell-id prefix 196 | vpc_id=$(aws ec2 describe-vpcs \ 197 | --filters "Name=tag:Name,Values=Cell-${cell_id}*" \ 198 | --query "Vpcs[0].VpcId" \ 199 | --output text) 200 | 201 | if [ -z "$vpc_id" ] || [ "$vpc_id" == "None" ]; then 202 | echo -e "${RED}No VPC found with cell-id prefix '${cell_id}' in region ${REGION}${NC}" 203 | return 1 204 | fi 205 | 206 | echo -e "${YELLOW}Found VPC: $vpc_id${NC}" 207 | 208 | # Retrieve the default NACL for the VPC 209 | nacl_id=$(aws ec2 describe-network-acls \ 210 | --filters "Name=vpc-id,Values=${vpc_id}" "Name=default,Values=true" \ 211 | --query "NetworkAcls[0].NetworkAclId" \ 212 | --output text) 213 | 214 | if [ -z "$nacl_id" ] || [ "$nacl_id" == "None" ]; then 215 | echo -e "${RED}No default NACL found for VPC ${vpc_id}${NC}" 216 | return 1 217 | fi 218 | 219 | echo -e "${YELLOW}Found NACL: $nacl_id${NC}" 220 | 221 | # Add deny rules for this cell 222 | for base_rule_number in "${!DOMAINS[@]}"; do 223 | domain="${DOMAINS[$base_rule_number]}" 224 | domain="${domain/REGION/$REGION}" 225 | add_deny_rules "$nacl_id" "$base_rule_number" "$domain" 226 | done 227 | 228 | # Store NACL ID for later cleanup 229 | NACL_IDS["$cell_id"]="$nacl_id" 230 | } 231 | 232 | # Apply the blocks for each cell 233 | process_cells() { 234 | # Process each cell 235 | for cell_id in "${CELL_IDS[@]}"; do 236 | process_cell "$cell_id" 237 | done 238 | } 239 | 240 | # Update the cleanup and rollback code 241 | cleanup() { 242 | echo -e "\n${YELLOW}Cleaning up NACL rules...${NC}" 243 | for cell_id in "${CELL_IDS[@]}"; do 244 | revert_cell "$cell_id" 245 | done 246 | echo -e "\n${GREEN}Cleanup completed${NC}" 247 | exit 0 248 | } 249 | 250 | # Function to display countdown timer 251 | display_countdown() { 252 | local remaining=$DURATION 253 | while [ $remaining -gt 0 ]; do 254 | printf "\r${YELLOW}Experiment in progress. Time remaining: %02d:%02d${NC}" $((remaining/60)) $((remaining%60)) 255 | sleep 1 256 | remaining=$((remaining-1)) 257 | done 258 | printf "\n" 259 | } 260 | 261 | # Set up trap for cleanup on script interruption 262 | trap cleanup SIGINT SIGTERM 263 | 264 | process_cells 265 | 266 | echo -e "\n${YELLOW}NACL updates complete${NC}" 267 | 268 | # Display countdown timer for the specified duration 269 | display_countdown 270 | 271 | echo -e "${YELLOW}Experiment completed, NACL rule rollback started${NC}" 272 | 273 | cleanup 274 | 275 | echo -e "${GREEN}Experiment completed. All NACL rules rollback complete.${NC}" 276 | -------------------------------------------------------------------------------- /lib/saas-management/cell-provisioning-system/src/stepfunctions/deploymentstatemachine.asl.json: -------------------------------------------------------------------------------- 1 | { 2 | "StartAt": "Assign total waves", 3 | "States": { 4 | "Assign total waves": { 5 | "Type": "Pass", 6 | "Next": "Iterator", 7 | "Result": { 8 | "total_waves": "2", 9 | "index": 0, 10 | "step": 1 11 | }, 12 | "ResultPath": "$.iterator" 13 | }, 14 | "Iterator": { 15 | "Type": "Task", 16 | "Resource": "arn:aws:states:::lambda:invoke", 17 | "OutputPath": "$.Payload", 18 | "Parameters": { 19 | "Payload.$": "$", 20 | "FunctionName": "${ITERATOR_LAMBDA_ARN}:$LATEST" 21 | }, 22 | "Retry": [ 23 | { 24 | "ErrorEquals": [ 25 | "Lambda.ServiceException", 26 | "Lambda.AWSLambdaException", 27 | "Lambda.SdkClientException", 28 | "Lambda.TooManyRequestsException" 29 | ], 30 | "IntervalSeconds": 2, 31 | "MaxAttempts": 6, 32 | "BackoffRate": 2 33 | } 34 | ], 35 | "Next": "Map State" 36 | }, 37 | "Map State": { 38 | "Type": "Map", 39 | "Iterator": { 40 | "StartAt": "Instance in current wave?", 41 | "States": { 42 | "Instance in current wave?": { 43 | "Type": "Choice", 44 | "Choices": [ 45 | { 46 | "Variable": "$.stack.waveNumber", 47 | "NumericEqualsPath": "$.current_wave_number", 48 | "Next": "CellStackExists?" 49 | } 50 | ], 51 | "Default": "Skip Deployment" 52 | }, 53 | "CellStackExists?": { 54 | "Type": "Task", 55 | "Next": "Can we update Stack?", 56 | "Parameters": { 57 | "StackName.$": "$.stack.cellStackName" 58 | }, 59 | "Resource": "arn:aws:states:::aws-sdk:cloudformation:describeStacks", 60 | "Catch": [ 61 | { 62 | "ErrorEquals": [ 63 | "States.TaskFailed" 64 | ], 65 | "Next": "DeployCellCDK", 66 | "ResultPath": "$.TaskResult" 67 | } 68 | ], 69 | "ResultPath": "$.TaskResult" 70 | }, 71 | "Can we update Stack?": { 72 | "Type": "Choice", 73 | "Choices": [ 74 | { 75 | "Or": [ 76 | { 77 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 78 | "StringEquals": "CREATE_COMPLETE" 79 | }, 80 | { 81 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 82 | "StringEquals": "ROLLBACK_COMPLETE" 83 | }, 84 | { 85 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 86 | "StringEquals": "UPDATE_COMPLETE" 87 | }, 88 | { 89 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 90 | "StringEquals": "UPDATE_ROLLBACK_COMPLETE" 91 | } 92 | ], 93 | "Next": "DeployCellCDK" 94 | } 95 | ], 96 | "Default": "Skip Deployment" 97 | }, 98 | "DeployCellCDK": { 99 | "Type": "Task", 100 | "Next": "Get Deployment Status", 101 | "Resource": "arn:aws:states:::codebuild:startBuild.sync", 102 | "Parameters": { 103 | "ProjectName": "${CODE_BUILD_PROJECT_NAME}", 104 | "EnvironmentVariablesOverride": [ 105 | { 106 | "Name": "STACK_NAME", 107 | "Value.$": "$.stack.cellStackName" 108 | }, 109 | { 110 | "Name": "CELL_ID", 111 | "Value.$": "$.stack.cellId" 112 | }, 113 | { 114 | "Name": "CELL_SIZE", 115 | "Value.$": "$.stack.cellSize" 116 | }, 117 | { 118 | "Name": "CODE_COMMIT_ID", 119 | "Value.$": "$.stack.commitId" 120 | } 121 | ] 122 | }, 123 | "ResultPath": null 124 | }, 125 | "Skip Deployment": { 126 | "Type": "Pass", 127 | "End": true 128 | }, 129 | "Get Deployment Status": { 130 | "Type": "Task", 131 | "Next": "Deployment Complete?", 132 | "Parameters": { 133 | "StackName.$": "$.stack.cellStackName" 134 | }, 135 | "Resource": "arn:aws:states:::aws-sdk:cloudformation:describeStacks", 136 | "ResultPath": "$.TaskResult" 137 | }, 138 | "Deployment Complete?": { 139 | "Type": "Choice", 140 | "Choices": [ 141 | { 142 | "Or": [ 143 | { 144 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 145 | "StringEquals": "UPDATE_COMPLETE" 146 | }, 147 | { 148 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 149 | "StringEquals": "CREATE_COMPLETE" 150 | } 151 | ], 152 | "Next": "Update CellStack with Latest commitid" 153 | } 154 | ], 155 | "Default": "Deployment Failed" 156 | }, 157 | "Update CellStack with Latest commitid": { 158 | "Type": "Task", 159 | "Resource": "arn:aws:states:::dynamodb:updateItem", 160 | "Parameters": { 161 | "TableName": "${CELL_MANAGEMENT_TABLE_NAME}", 162 | "Key": { 163 | "PK": { 164 | "S.$": "$.stack.cellId" 165 | } 166 | }, 167 | "UpdateExpression": "set codeCommitId=:codeCommitId", 168 | "ExpressionAttributeValues": { 169 | ":codeCommitId": { 170 | "S.$": "$.stack.commitId" 171 | } 172 | } 173 | }, 174 | "Next": "Map", 175 | "ResultPath": null 176 | }, 177 | "Map": { 178 | "Type": "Map", 179 | "ItemProcessor": { 180 | "ProcessorConfig": { 181 | "Mode": "INLINE" 182 | }, 183 | "StartAt": "DeployTenantCDK", 184 | "States": { 185 | "DeployTenantCDK": { 186 | "Type": "Task", 187 | "Next": "Update TenantStack with Latest ProductImage", 188 | "Resource": "arn:aws:states:::codebuild:startBuild.sync", 189 | "Parameters": { 190 | "ProjectName": "${TENANT_MGMT_CODE_BUILD_PROJECT_NAME}", 191 | "EnvironmentVariablesOverride": [ 192 | { 193 | "Name": "TENANT_ID", 194 | "Value.$": "$.tenantStack.tenantId" 195 | }, 196 | { 197 | "Name": "CELL_ID", 198 | "Value.$": "$.tenantStack.cellId" 199 | }, 200 | { 201 | "Name": "CELL_SIZE", 202 | "Value.$": "$.tenantStack.cellSize" 203 | }, 204 | { 205 | "Name": "TENANT_EMAIL", 206 | "Value.$": "$.tenantStack.tenantEmail" 207 | }, 208 | { 209 | "Name": "TENANT_LISTENER_PRIORITY", 210 | "Value.$": "$.tenantStack.tenantListenerPriority" 211 | }, 212 | { 213 | "Name": "PRODUCT_IMAGE_VERSION", 214 | "Value.$": "$.tenantStack.productImageVersion" 215 | } 216 | ] 217 | }, 218 | "ResultPath": null 219 | }, 220 | "Update TenantStack with Latest ProductImage": { 221 | "Type": "Task", 222 | "Resource": "arn:aws:states:::dynamodb:updateItem", 223 | "Parameters": { 224 | "TableName": "${CELL_MANAGEMENT_TABLE_NAME}", 225 | "Key": { 226 | "PK": { 227 | "S.$": "$.tenantStack.PK" 228 | } 229 | }, 230 | "UpdateExpression": "set product_image_version=:product_image_version", 231 | "ExpressionAttributeValues": { 232 | ":product_image_version": { 233 | "S.$": "$.tenantStack.productImageVersion" 234 | } 235 | } 236 | }, 237 | "End": true 238 | } 239 | } 240 | }, 241 | "Next": "Deployment Succeeded", 242 | "ResultPath": null, 243 | "ItemsPath": "$.stack.tenantsInCell", 244 | "ItemSelector": { 245 | "tenantStack.$": "$$.Map.Item.Value" 246 | } 247 | }, 248 | "Deployment Succeeded": { 249 | "Comment": "Placeholder for a state which handles the success.", 250 | "Type": "Pass", 251 | "End": true 252 | }, 253 | "Deployment Failed": { 254 | "Type": "Fail", 255 | "Error": "Instance deployment failed" 256 | } 257 | } 258 | }, 259 | "ItemsPath": "$.stacks", 260 | "ResultPath": null, 261 | "ItemSelector": { 262 | "stack.$": "$$.Map.Item.Value", 263 | "current_wave_number.$": "$.iterator.index" 264 | }, 265 | "Next": "All Waves Deployed?" 266 | }, 267 | "All Waves Deployed?": { 268 | "Type": "Choice", 269 | "Choices": [ 270 | { 271 | "Variable": "$.iterator.continue", 272 | "BooleanEquals": true, 273 | "Next": "Iterator" 274 | } 275 | ], 276 | "Default": "Deployment Complete" 277 | }, 278 | "Deployment Complete": { 279 | "Type": "Pass", 280 | "End": true 281 | } 282 | } 283 | } -------------------------------------------------------------------------------- /Solution/lab04/deploymentstatemachine.asl.json: -------------------------------------------------------------------------------- 1 | { 2 | "StartAt": "Assign total waves", 3 | "States": { 4 | "Assign total waves": { 5 | "Type": "Pass", 6 | "Next": "Iterator", 7 | "Result": { 8 | "total_waves": "2", 9 | "index": 0, 10 | "step": 1 11 | }, 12 | "ResultPath": "$.iterator" 13 | }, 14 | "Iterator": { 15 | "Type": "Task", 16 | "Resource": "arn:aws:states:::lambda:invoke", 17 | "OutputPath": "$.Payload", 18 | "Parameters": { 19 | "Payload.$": "$", 20 | "FunctionName": "${ITERATOR_LAMBDA_ARN}:$LATEST" 21 | }, 22 | "Retry": [ 23 | { 24 | "ErrorEquals": [ 25 | "Lambda.ServiceException", 26 | "Lambda.AWSLambdaException", 27 | "Lambda.SdkClientException", 28 | "Lambda.TooManyRequestsException" 29 | ], 30 | "IntervalSeconds": 2, 31 | "MaxAttempts": 6, 32 | "BackoffRate": 2 33 | } 34 | ], 35 | "Next": "Map State" 36 | }, 37 | "Map State": { 38 | "Type": "Map", 39 | "Iterator": { 40 | "StartAt": "Instance in current wave?", 41 | "States": { 42 | "Instance in current wave?": { 43 | "Type": "Choice", 44 | "Choices": [ 45 | { 46 | "Variable": "$.stack.waveNumber", 47 | "NumericEqualsPath": "$.current_wave_number", 48 | "Next": "CellStackExists?" 49 | } 50 | ], 51 | "Default": "Skip Deployment" 52 | }, 53 | "CellStackExists?": { 54 | "Type": "Task", 55 | "Next": "Can we update Stack?", 56 | "Parameters": { 57 | "StackName.$": "$.stack.cellStackName" 58 | }, 59 | "Resource": "arn:aws:states:::aws-sdk:cloudformation:describeStacks", 60 | "Catch": [ 61 | { 62 | "ErrorEquals": [ 63 | "States.TaskFailed" 64 | ], 65 | "Next": "DeployCellCDK", 66 | "ResultPath": "$.TaskResult" 67 | } 68 | ], 69 | "ResultPath": "$.TaskResult" 70 | }, 71 | "Can we update Stack?": { 72 | "Type": "Choice", 73 | "Choices": [ 74 | { 75 | "Or": [ 76 | { 77 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 78 | "StringEquals": "CREATE_COMPLETE" 79 | }, 80 | { 81 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 82 | "StringEquals": "ROLLBACK_COMPLETE" 83 | }, 84 | { 85 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 86 | "StringEquals": "UPDATE_COMPLETE" 87 | }, 88 | { 89 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 90 | "StringEquals": "UPDATE_ROLLBACK_COMPLETE" 91 | } 92 | ], 93 | "Next": "DeployCellCDK" 94 | } 95 | ], 96 | "Default": "Skip Deployment" 97 | }, 98 | "DeployCellCDK": { 99 | "Type": "Task", 100 | "Next": "Get Deployment Status", 101 | "Resource": "arn:aws:states:::codebuild:startBuild.sync", 102 | "Parameters": { 103 | "ProjectName": "${CODE_BUILD_PROJECT_NAME}", 104 | "EnvironmentVariablesOverride": [ 105 | { 106 | "Name": "STACK_NAME", 107 | "Value.$": "$.stack.cellStackName" 108 | }, 109 | { 110 | "Name": "CELL_ID", 111 | "Value.$": "$.stack.cellId" 112 | }, 113 | { 114 | "Name": "CELL_SIZE", 115 | "Value.$": "$.stack.cellSize" 116 | }, 117 | { 118 | "Name": "CODE_COMMIT_ID", 119 | "Value.$": "$.stack.commitId" 120 | } 121 | ] 122 | }, 123 | "ResultPath": null 124 | }, 125 | "Skip Deployment": { 126 | "Type": "Pass", 127 | "End": true 128 | }, 129 | "Get Deployment Status": { 130 | "Type": "Task", 131 | "Next": "Deployment Complete?", 132 | "Parameters": { 133 | "StackName.$": "$.stack.cellStackName" 134 | }, 135 | "Resource": "arn:aws:states:::aws-sdk:cloudformation:describeStacks", 136 | "ResultPath": "$.TaskResult" 137 | }, 138 | "Deployment Complete?": { 139 | "Type": "Choice", 140 | "Choices": [ 141 | { 142 | "Or": [ 143 | { 144 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 145 | "StringEquals": "UPDATE_COMPLETE" 146 | }, 147 | { 148 | "Variable": "$.TaskResult.Stacks[0].StackStatus", 149 | "StringEquals": "CREATE_COMPLETE" 150 | } 151 | ], 152 | "Next": "Update CellStack with Latest commitid" 153 | } 154 | ], 155 | "Default": "Deployment Failed" 156 | }, 157 | "Update CellStack with Latest commitid": { 158 | "Type": "Task", 159 | "Resource": "arn:aws:states:::dynamodb:updateItem", 160 | "Parameters": { 161 | "TableName": "${CELL_MANAGEMENT_TABLE_NAME}", 162 | "Key": { 163 | "PK": { 164 | "S.$": "$.stack.cellId" 165 | } 166 | }, 167 | "UpdateExpression": "set codeCommitId=:codeCommitId", 168 | "ExpressionAttributeValues": { 169 | ":codeCommitId": { 170 | "S.$": "$.stack.commitId" 171 | } 172 | } 173 | }, 174 | "Next": "Map", 175 | "ResultPath": null 176 | }, 177 | "Map": { 178 | "Type": "Map", 179 | "ItemProcessor": { 180 | "ProcessorConfig": { 181 | "Mode": "INLINE" 182 | }, 183 | "StartAt": "DeployTenantCDK", 184 | "States": { 185 | "DeployTenantCDK": { 186 | "Type": "Task", 187 | "Next": "Update TenantStack with Latest ProductImage", 188 | "Resource": "arn:aws:states:::codebuild:startBuild.sync", 189 | "Parameters": { 190 | "ProjectName": "${TENANT_MGMT_CODE_BUILD_PROJECT_NAME}", 191 | "EnvironmentVariablesOverride": [ 192 | { 193 | "Name": "TENANT_ID", 194 | "Value.$": "$.tenantStack.tenantId" 195 | }, 196 | { 197 | "Name": "CELL_ID", 198 | "Value.$": "$.tenantStack.cellId" 199 | }, 200 | { 201 | "Name": "CELL_SIZE", 202 | "Value.$": "$.tenantStack.cellSize" 203 | }, 204 | { 205 | "Name": "TENANT_EMAIL", 206 | "Value.$": "$.tenantStack.tenantEmail" 207 | }, 208 | { 209 | "Name": "TENANT_LISTENER_PRIORITY", 210 | "Value.$": "$.tenantStack.tenantListenerPriority" 211 | }, 212 | { 213 | "Name": "PRODUCT_IMAGE_VERSION", 214 | "Value.$": "$.tenantStack.productImageVersion" 215 | } 216 | ] 217 | }, 218 | "ResultPath": null 219 | }, 220 | "Update TenantStack with Latest ProductImage": { 221 | "Type": "Task", 222 | "Resource": "arn:aws:states:::dynamodb:updateItem", 223 | "Parameters": { 224 | "TableName": "${CELL_MANAGEMENT_TABLE_NAME}", 225 | "Key": { 226 | "PK": { 227 | "S.$": "$.tenantStack.PK" 228 | } 229 | }, 230 | "UpdateExpression": "set product_image_version=:product_image_version", 231 | "ExpressionAttributeValues": { 232 | ":product_image_version": { 233 | "S.$": "$.tenantStack.productImageVersion" 234 | } 235 | } 236 | }, 237 | "End": true 238 | } 239 | } 240 | }, 241 | "Next": "Deployment Succeeded", 242 | "ResultPath": null, 243 | "ItemsPath": "$.stack.tenantsInCell", 244 | "ItemSelector": { 245 | "tenantStack.$": "$$.Map.Item.Value" 246 | } 247 | }, 248 | "Deployment Succeeded": { 249 | "Comment": "Placeholder for a state which handles the success.", 250 | "Type": "Pass", 251 | "Next": "Wait for Cell Bake Time" 252 | }, 253 | "Wait for Cell Bake Time": { 254 | "Type": "Wait", 255 | "Seconds": 300, 256 | "Next": "Check CloudWatch Alarm" 257 | }, 258 | "Check CloudWatch Alarm": { 259 | "Type": "Task", 260 | "Resource": "arn:aws:states:::aws-sdk:cloudwatch:describeAlarms", 261 | "Parameters": { 262 | "AlarmNames": ["${AGGREGATE_ALARM_HTTP_5XX}"] 263 | }, 264 | "ResultSelector": { 265 | "state.$": "$.MetricAlarms[0].StateValue" 266 | }, 267 | "ResultPath": "$.alarmStatus", 268 | "Next": "Evaluate Alarm State" 269 | }, 270 | "Evaluate Alarm State": { 271 | "Type": "Choice", 272 | "Choices": [ 273 | { 274 | "Variable": "$.alarmStatus.state", 275 | "StringEquals": "OK", 276 | "Next": "Cell Healthcheck Successful" 277 | } 278 | ], 279 | "Default": "Cell Healthcheck Failed" 280 | }, 281 | "Cell Healthcheck Failed": { 282 | "Type": "Fail", 283 | "Error": "Cell deployment is successful, yet health check failed. Blocking deployment" 284 | }, 285 | "Cell Healthcheck Successful": { 286 | "Type": "Pass", 287 | "End": true 288 | }, 289 | "Deployment Failed": { 290 | "Type": "Fail", 291 | "Error": "Instance deployment failed" 292 | } 293 | } 294 | }, 295 | "ItemsPath": "$.stacks", 296 | "ResultPath": null, 297 | "ItemSelector": { 298 | "stack.$": "$$.Map.Item.Value", 299 | "current_wave_number.$": "$.iterator.index" 300 | }, 301 | "Next": "All Waves Deployed?" 302 | }, 303 | "All Waves Deployed?": { 304 | "Type": "Choice", 305 | "Choices": [ 306 | { 307 | "Variable": "$.iterator.continue", 308 | "BooleanEquals": true, 309 | "Next": "Iterator" 310 | } 311 | ], 312 | "Default": "Deployment Complete" 313 | }, 314 | "Deployment Complete": { 315 | "Type": "Pass", 316 | "End": true 317 | } 318 | } 319 | } --------------------------------------------------------------------------------