diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f07144..7c3f73e 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.2.0] - 2023-08-03 + +### Added + +- [Service Catalog AppRegistry](https://docs.aws.amazon.com/servicecatalog/latest/arguide/intro-app-registry.html) resource to register the CloudFormation templates and underlying resources as an application in both Service Catalog AppRegistry and AWS Systems Manager Application Manager. + +### Updated + +- AWS Cloud Development Kit (CDK) v2. +- Python runtime 3.10. +- Source code folders structure. +- CDK unit tests. +- Python libraries. + + ## [2.1.2] - 2023-04-17 ### Updated diff --git a/NOTICE.txt b/NOTICE.txt index ef91f44..3becdc2 100755 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -11,15 +11,107 @@ THIRD PARTY COMPONENTS ********************** This software includes third party software subject to the following copyrights: -aws-cdk under the Apache License Version 2.0 -AWS SDK under the Apache License Version 2.0 -boto3 under the Apache License Version 2.0 -pytest-env under the Massachusetts Institute of Technology (MIT) license -crhelper under the Apache License Version 2.0 -jsii under the Apache License Version 2.0 -pytest under the Massachusetts Institute of Technology (MIT) license -pytest-cov under the Massachusetts Institute of Technology (MIT) license -pytest-mock under the Massachusetts Institute of Technology (MIT) license -requests under the Apache License Version 2.0 -sagemaker under the Apache License Version 2.0 -urllib3 under the Massachusetts Institute of Technology (MIT) license \ No newline at end of file +Jinja2 BSD License +MarkupSafe BSD License +PyYAML MIT License +Werkzeug BSD License +attrs MIT License +aws-cdk-lib Apache-2.0 +aws-cdk.asset-awscli-v1 Apache-2.0 +aws-cdk.asset-kubectl-v20 Apache-2.0 +aws-cdk.asset-node-proxy-agent-v5 Apache-2.0 +aws-cdk.aws-servicecatalogappregistry-alpha Apache-2.0 +aws-sam-translator Apache Software License +aws-solutions-constructs.aws-apigateway-lambda Apache-2.0 +aws-solutions-constructs.aws-lambda-sagemakerendpoint Apache-2.0 +aws-solutions-constructs.core Apache-2.0 +aws-xray-sdk Apache Software License +boto3 Apache Software License +botocore Apache Software License +cattrs MIT License +certifi Mozilla Public License 2.0 (MPL 2.0) +cffi MIT License +cfn-lint MIT License +charset-normalizer MIT License +cloudpickle BSD License +constructs Apache-2.0 +contextlib2 Apache Software License; Python Software Foundation License +coverage Apache Software License +crhelper Apache Software License +cryptography Apache Software License; BSD License +dill BSD License +docker Apache Software License +ecdsa MIT +exceptiongroup MIT License +google-pasta Apache Software License +graphql-core MIT License +idna BSD License +importlib-metadata Apache Software License +importlib-resources Apache Software License +iniconfig MIT License +jmespath MIT License +jschema-to-python MIT License +jsii Apache Software License +jsondiff MIT License +jsonpatch BSD License +jsonpickle BSD License +jsonpointer BSD License +jsonschema MIT License +jsonschema-spec Apache Software License +jsonschema-specifications MIT License +junit-xml Freely Distributable; MIT License +lazy-object-proxy BSD License +moto Apache Software License +mpmath BSD License +multiprocess BSD License +networkx BSD License +numpy BSD License +openapi-schema-validator BSD License +openapi-spec-validator Apache Software License +packaging Apache Software License; BSD License +pandas BSD License +pathable Other/Proprietary License +pathos BSD License +pbr Apache Software License +platformdirs MIT License +pluggy MIT License +pox BSD License +ppft BSD License +protobuf BSD-3-Clause +protobuf3-to-dict Public Domain +publication MIT License +pyasn1 BSD License +pycparser BSD License +pydantic MIT License +pyparsing MIT License +pytest MIT License +pytest-cov MIT License +python-dateutil Apache Software License; BSD License +python-jose MIT License +pytz MIT License +referencing MIT License +regex Apache Software License +requests Apache Software License +responses Apache 2.0 +rfc3339-validator MIT License +rpds-py MIT License +rsa Apache Software License +s3transfer Apache Software License +sagemaker Apache Software License +sarif-om MIT License +schema MIT License +six MIT License +smdebug-rulesconfig Apache Software License +sshpubkeys BSD License +sympy BSD License +tblib BSD License +tomli MIT License +typeguard MIT License +types-PyYAML Apache Software License +typing_extensions Python Software Foundation License +tzdata Apache Software License +urllib3 MIT License +websocket-client Apache Software License +wrapt BSD License +xmltodict MIT License +zipp MIT License \ No newline at end of file diff --git a/README.md b/README.md index 0c9f5af..94ebb02 100755 --- a/README.md +++ b/README.md @@ -52,29 +52,40 @@ The solution uses [AWS Organizations](https://aws.amazon.com/organizations/) and Upon successfully cloning the repository into your local development environment but **prior** to running the initialization script, you will see the following file structure in your editor: ``` -├── deployment [folder containing build scripts] -│ ├── cdk-solution-helper [A helper function to help deploy lambda function code through S3 buckets] -│ ├── build-s3-dist.sh [A script to prepare the solution for deploying from source code] -├── source [source code containing CDK App and lambda functions] -│ ├── lambdas [folder containing source code the lambda functions] -│ │ ├── custom_resource [lambda function to copy necessary resources from aws solutions bucket] -│ │ ├── pipeline_orchestration [lambda function to provision ML pipelines] -│ └── lib -│ ├── blueprints [folder containing implementations of different types of ML pipelines supported by this solution] -│ │ ├── byom [implementation of bring-your-own-model ML pipeline] -│ │ │ ├── lambdas [folder containing source code the lambda functions] -│ │ │ └── pipeline_definitions [folder containing CDK implementation of pipeline stages in BYOM] -│ ├── aws_mlops_stack.py [CDK implementation of the main framework ] -│ └── conditional_resource.py [a helper file to enable conditional resource provisioning in CDK] -├── .gitignore -├── CHANGELOG.md [required for every solution to include changes based on version to auto[uild release notes] -├── CODE_OF_CONDUCT.md [standardized open source file for all solutions] -├── CONTRIBUTING.md [standardized open source file for all solutions] -├── LICENSE.txt [required open source file for all solutions - should contain the Apache 2.0 license] -├── NOTICE.txt [required open source file for all solutions - should contain references to all 3rd party libraries] -└── README.md [required file for all solutions] - -* Note: Not all languages are supported at this time. Actual appearance may vary depending on release. +├── CHANGELOG.md +├── CODE_OF_CONDUCT.md +├── CONTRIBUTING.md +├── LICENSE.txt +├── NOTICE.txt +├── README.md +├── deployment [folder containing build/test scripts] +│   ├── build-s3-dist.sh +│ ├── run-all-tests.sh +│   ├── cdk-solution-helper +└── source + ├── infrastructure [folder containing CDK code and lambdas for ML pipelines] + │   ├── lib + │   │   ├── blueprints + │   │   │   ├── aspects + │   │   │   ├── lambdas + │   │   │   │   ├── batch_transform + │   │   │   │   ├── create_baseline_job + │   │   │   │   ├── create_model_training_job + │   │   │   │   ├── create_sagemaker_autopilot_job + │   │   │   │   ├── create_update_cf_stackset + │   │   │   │   ├── inference + │   │   │   │   ├── invoke_lambda_custom_resource + │   │   │   │   └── sagemaker_layer + │   │   │   ├── ml_pipelines + │   │   │   └── pipeline_definitions + │   │   └── mlops_orchestrator_stack.py + │   └── test [folder containing CDK unit tests] + ├── lambdas [folder containing lambdas for the main templates] + │   ├── custom_resource + │   ├── pipeline_orchestration + │   └── solution_helper + ├── requirements-test.txt + └── requirements.txt ``` ## Creating a custom build @@ -142,7 +153,7 @@ Please refer to the [Uninstall the solution section](https://docs.aws.amazon.com ## Collection of operational metrics -This solution collects anonymous operational metrics to help AWS improve the quality and features of the solution. For more information, including how to disable this capability, please see the [implementation guide](https://docs.aws.amazon.com/solutions/latest/mlops-workload-orchestrator/operational-metrics.html). +This solution collects anonymized operational metrics to help AWS improve the quality and features of the solution. For more information, including how to disable this capability, please see the [implementation guide](https://docs.aws.amazon.com/solutions/latest/mlops-workload-orchestrator/operational-metrics.html). ## Known Issues diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh index 540c055..9ad744a 100755 --- a/deployment/build-s3-dist.sh +++ b/deployment/build-s3-dist.sh @@ -28,7 +28,7 @@ set -e # Important: CDK global version number -cdk_version=1.126.0 +cdk_version=2.87.0 # Check to see if the required parameters have been provided: if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then @@ -68,18 +68,20 @@ echo "-------------------------------------------------------------------------- echo "cd $source_dir" cd $source_dir -# setup lambda layers (building sagemaker layer using lambda build environment for python 3.8) -echo 'docker run --entrypoint /bin/bash -v "$source_dir"/lib/blueprints/byom/lambdas/sagemaker_layer:/var/task public.ecr.aws/lambda/python:3.9 -c "cat requirements.txt; pip3 install -r requirements.txt -t ./python; exit"' -docker run --entrypoint /bin/bash -v "$source_dir"/lib/blueprints/byom/lambdas/sagemaker_layer:/var/task public.ecr.aws/lambda/python:3.9 -c "cat requirements.txt; pip3 install -r requirements.txt -t ./python; exit" +# setup lambda layers (building sagemaker layer using lambda build environment for python 3.10) +echo 'docker run --entrypoint /bin/bash -v "$source_dir"/infrastructure/lib/blueprints/lambdas/sagemaker_layer:/var/task public.ecr.aws/lambda/python:3.10 -c "cat requirements.txt; pip3 install -r requirements.txt -t ./python; exit"' +docker run --entrypoint /bin/bash -v "$source_dir"/infrastructure/lib/blueprints/lambdas/sagemaker_layer:/var/task public.ecr.aws/lambda/python:3.10 -c "cat requirements.txt; pip3 install -r requirements.txt -t ./python; exit" # Remove tests and cache stuff (to reduce size) -find "$source_dir"/lib/blueprints/byom/lambdas/sagemaker_layer/python -type d -name "tests" -exec rm -rfv {} + -find "$source_dir"/lib/blueprints/byom/lambdas/sagemaker_layer/python -type d -name "__pycache__" -exec rm -rfv {} + +find "$source_dir"/infrastructure/lib/blueprints/lambdas/sagemaker_layer/python -type d -name "tests" -exec rm -rfv {} + +find "$source_dir"/infrastructure/lib/blueprints/lambdas/sagemaker_layer/python -type d -name "__pycache__" -exec rm -rfv {} + echo "python3 -m venv .venv-prod" python3 -m venv .venv-prod echo "source .venv-prod/bin/activate" source .venv-prod/bin/activate +echo "upgrading pip -> python3 -m pip install --upgrade pip" +python3 -m pip install --upgrade pip echo "pip install -r requirements.txt" pip install -r requirements.txt @@ -92,8 +94,8 @@ echo "pip install -r ./lambdas/solution_helper/requirements.txt -t ./lambdas/sol pip install -r ./lambdas/solution_helper/requirements.txt -t ./lambdas/solution_helper/ # setup crhelper for invoke lambda custom resource -echo "pip install -r ./lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/requirements.txt -t ./lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/" -pip install -r ./lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/requirements.txt -t ./lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/ +echo "pip install -r ./infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/requirements.txt -t ./infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/" +pip install -r ./infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/requirements.txt -t ./infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/ echo "------------------------------------------------------------------------------" echo "[Init] Install dependencies for the cdk-solution-helper" @@ -114,64 +116,94 @@ cd $source_dir echo "npm install -g aws-cdk@$cdk_version" npm install -g aws-cdk@$cdk_version +# move to the infrastructure dir +cd $source_dir/infrastructure #Run 'cdk synth for BYOM blueprints -echo "cdk synth DataQualityModelMonitorStack > lib/blueprints/byom/byom_data_quality_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth DataQualityModelMonitorStack > lib/blueprints/byom/byom_data_quality_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth ModelQualityModelMonitorStack > lib/blueprints/byom/byom_model_quality_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth ModelQualityModelMonitorStack > lib/blueprints/byom/byom_model_quality_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth ModelBiasModelMonitorStack > lib/blueprints/byom/byom_model_bias_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth ModelBiasModelMonitorStack > lib/blueprints/byom/byom_model_bias_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth ModelExplainabilityModelMonitorStack > lib/blueprints/byom/byom_model_explainability_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth ModelExplainabilityModelMonitorStack > lib/blueprints/byom/byom_model_explainability_monitor.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth SingleAccountCodePipelineStack > lib/blueprints/byom/single_account_codepipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth SingleAccountCodePipelineStack > lib/blueprints/byom/single_account_codepipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth MultiAccountCodePipelineStack > lib/blueprints/byom/multi_account_codepipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth MultiAccountCodePipelineStack > lib/blueprints/byom/multi_account_codepipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth BYOMRealtimePipelineStack > lib/blueprints/byom/byom_realtime_inference_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth BYOMRealtimePipelineStack > lib/blueprints/byom/byom_realtime_inference_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth BYOMCustomAlgorithmImageBuilderStack > lib/blueprints/byom/byom_custom_algorithm_image_builder.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth BYOMCustomAlgorithmImageBuilderStack > lib/blueprints/byom/byom_custom_algorithm_image_builder.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth BYOMBatchStack > lib/blueprints/byom/byom_batch_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth BYOMBatchStack > lib/blueprints/byom/byom_batch_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth AutopilotJobStack > lib/blueprints/byom/autopilot_training_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth AutopilotJobStack > lib/blueprints/byom/autopilot_training_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth TrainingJobStack > lib/blueprints/byom/model_training_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth TrainingJobStack > lib/blueprints/byom/model_training_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false -echo "cdk synth HyperparamaterTunningJobStack > lib/blueprints/byom/model_hyperparameter_tunning_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false" -cdk synth HyperparamaterTunningJobStack > lib/blueprints/byom/model_hyperparameter_tunning_pipeline.yaml --path-metadata false --version-reporting false --generate-bootstrap-version-rule false +echo "cdk synth DataQualityModelMonitorStack > $staging_dist_dir/byom_data_quality_monitor.yaml --path-metadata false --version-reporting false" +cdk synth DataQualityModelMonitorStack > $staging_dist_dir/byom_data_quality_monitor.yaml --path-metadata false --version-reporting false +echo "cdk synth ModelQualityModelMonitorStack > $staging_dist_dir/byom_model_quality_monitor.yaml --path-metadata false --version-reporting false" +cdk synth ModelQualityModelMonitorStack > $staging_dist_dir/byom_model_quality_monitor.yaml --path-metadata false --version-reporting false +echo "cdk synth ModelBiasModelMonitorStack > $staging_dist_dir/byom_model_bias_monitor.yaml --path-metadata false --version-reporting false" +cdk synth ModelBiasModelMonitorStack > $staging_dist_dir/byom_model_bias_monitor.yaml --path-metadata false --version-reporting false +echo "cdk synth ModelExplainabilityModelMonitorStack > $staging_dist_dir/byom_model_explainability_monitor.yaml --path-metadata false --version-reporting false" +cdk synth ModelExplainabilityModelMonitorStack > $staging_dist_dir/byom_model_explainability_monitor.yaml --path-metadata false --version-reporting false +echo "cdk synth SingleAccountCodePipelineStack > $staging_dist_dir/single_account_codepipeline.yaml --path-metadata false --version-reporting false" +cdk synth SingleAccountCodePipelineStack > $staging_dist_dir/single_account_codepipeline.yaml --path-metadata false --version-reporting false +echo "cdk synth MultiAccountCodePipelineStack > $staging_dist_dir/multi_account_codepipeline.yaml --path-metadata false --version-reporting false" +cdk synth MultiAccountCodePipelineStack > $staging_dist_dir/multi_account_codepipeline.yaml --path-metadata false --version-reporting false +echo "cdk synth BYOMRealtimePipelineStack > $staging_dist_dir/byom_realtime_inference_pipeline.yaml --path-metadata false --version-reporting false" +cdk synth BYOMRealtimePipelineStack > $staging_dist_dir/byom_realtime_inference_pipeline.yaml --path-metadata false --version-reporting false +echo "cdk synth BYOMCustomAlgorithmImageBuilderStack > $staging_dist_dir/byom_custom_algorithm_image_builder.yaml --path-metadata false --version-reporting false" +cdk synth BYOMCustomAlgorithmImageBuilderStack > $staging_dist_dir/byom_custom_algorithm_image_builder.yaml --path-metadata false --version-reporting false +echo "cdk synth BYOMBatchStack > $staging_dist_dir/byom_batch_pipeline.yaml --path-metadata false --version-reporting false" +cdk synth BYOMBatchStack > $staging_dist_dir/byom_batch_pipeline.yaml --path-metadata false --version-reporting false +echo "cdk synth AutopilotJobStack > $staging_dist_dir/autopilot_training_pipeline.yaml --path-metadata false --version-reporting false" +cdk synth AutopilotJobStack > $staging_dist_dir/autopilot_training_pipeline.yaml --path-metadata false --version-reporting false +echo "cdk synth TrainingJobStack > $staging_dist_dir/model_training_pipeline.yaml --path-metadata false --version-reporting false" +cdk synth TrainingJobStack > $staging_dist_dir/model_training_pipeline.yaml --path-metadata false --version-reporting false +echo "cdk synth HyperparamaterTunningJobStack > $staging_dist_dir/model_hyperparameter_tunning_pipeline.yaml --path-metadata false --version-reporting false" +cdk synth HyperparamaterTunningJobStack > $staging_dist_dir/model_hyperparameter_tunning_pipeline.yaml --path-metadata false --version-reporting false # Replace %%VERSION%% in other templates replace="s/%%VERSION%%/$3/g" -echo "sed -i -e $replace lib/blueprints/byom/byom_data_quality_monitor.yaml" -sed -i -e $replace lib/blueprints/byom/byom_data_quality_monitor.yaml -echo "sed -i -e $replace lib/blueprints/byom/byom_model_quality_monitor.yaml" -sed -i -e $replace lib/blueprints/byom/byom_model_quality_monitor.yaml -echo "sed -i -e $replace lib/blueprints/byom/byom_model_bias_monitor.yaml" -sed -i -e $replace lib/blueprints/byom/byom_model_bias_monitor.yaml -echo "sed -i -e $replace lib/blueprints/byom/byom_model_explainability_monitor.yaml" -sed -i -e $replace lib/blueprints/byom/byom_model_explainability_monitor.yaml -echo "sed -i -e $replace lib/blueprints/byom/byom_realtime_inference_pipeline.yaml" -sed -i -e $replace lib/blueprints/byom/byom_realtime_inference_pipeline.yaml -echo "sed -i -e $replace lib/blueprints/byom/single_account_codepipeline.yaml" -sed -i -e $replace lib/blueprints/byom/single_account_codepipeline.yaml -echo "sed -i -e $replace lib/blueprints/byom/multi_account_codepipeline.yaml" -sed -i -e $replace lib/blueprints/byom/multi_account_codepipeline.yaml -echo "sed -i -e $replace lib/blueprints/byom/byom_custom_algorithm_image_builder.yaml" -sed -i -e $replace lib/blueprints/byom/byom_custom_algorithm_image_builder.yaml -echo "sed -i -e $replace lib/blueprints/byom/byom_batch_pipeline.yaml" -sed -i -e $replace lib/blueprints/byom/byom_batch_pipeline.yaml -echo "sed -i -e $replace lib/blueprints/byom/autopilot_training_pipeline.yaml" -sed -i -e $replace lib/blueprints/byom/autopilot_training_pipeline.yaml -echo "sed -i -e $replace lib/blueprints/byom/model_training_pipeline.yaml" -sed -i -e $replace lib/blueprints/byom/model_training_pipeline.yaml -echo "sed -i -e $replace lib/blueprints/byom/model_hyperparameter_tunning_pipeline.yaml" -sed -i -e $replace lib/blueprints/byom/model_hyperparameter_tunning_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_data_quality_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_data_quality_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_model_quality_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_model_quality_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_model_bias_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_model_bias_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_model_explainability_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_model_explainability_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_realtime_inference_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/byom_realtime_inference_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/single_account_codepipeline.yaml" +sed -i -e $replace $staging_dist_dir/single_account_codepipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/multi_account_codepipeline.yaml" +sed -i -e $replace $staging_dist_dir/multi_account_codepipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_custom_algorithm_image_builder.yaml" +sed -i -e $replace $staging_dist_dir/byom_custom_algorithm_image_builder.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_batch_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/byom_batch_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/autopilot_training_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/autopilot_training_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/model_training_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/model_training_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/model_hyperparameter_tunning_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/model_hyperparameter_tunning_pipeline.yaml + +# replace %%SOLUTION_NAME%% for AppRegistry app +replace="s/%%SOLUTION_NAME%%/$2/g" +echo "sed -i -e $replace $staging_dist_dir/byom_data_quality_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_data_quality_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_model_quality_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_model_quality_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_model_bias_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_model_bias_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_model_explainability_monitor.yaml" +sed -i -e $replace $staging_dist_dir/byom_model_explainability_monitor.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_realtime_inference_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/byom_realtime_inference_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/single_account_codepipeline.yaml" +sed -i -e $replace $staging_dist_dir/single_account_codepipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/multi_account_codepipeline.yaml" +sed -i -e $replace $staging_dist_dir/multi_account_codepipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_custom_algorithm_image_builder.yaml" +sed -i -e $replace $staging_dist_dir/byom_custom_algorithm_image_builder.yaml +echo "sed -i -e $replace $staging_dist_dir/byom_batch_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/byom_batch_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/autopilot_training_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/autopilot_training_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/model_training_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/model_training_pipeline.yaml +echo "sed -i -e $replace $staging_dist_dir/model_hyperparameter_tunning_pipeline.yaml" +sed -i -e $replace $staging_dist_dir/model_hyperparameter_tunning_pipeline.yaml + # Run 'cdk synth' for main templates to generate raw solution outputs -echo "cdk synth mlops-workload-orchestrator-single-account --path-metadata false --version-reporting false --generate-bootstrap-version-rule false --output=$staging_dist_dir" -cdk synth mlops-workload-orchestrator-single-account --path-metadata false --version-reporting false --generate-bootstrap-version-rule false --output=$staging_dist_dir -echo "cdk synth mlops-workload-orchestrator-multi-account --path-metadata false --version-reporting false --generate-bootstrap-version-rule false --output=$staging_dist_dir" -cdk synth mlops-workload-orchestrator-multi-account --path-metadata false --version-reporting false --generate-bootstrap-version-rule false --output=$staging_dist_dir +echo "cdk synth mlops-workload-orchestrator-single-account --path-metadata false --version-reporting false --output=$staging_dist_dir" +cdk synth mlops-workload-orchestrator-single-account --path-metadata false --version-reporting false --output=$staging_dist_dir +echo "cdk synth mlops-workload-orchestrator-multi-account --path-metadata false --version-reporting false --output=$staging_dist_dir" +cdk synth mlops-workload-orchestrator-multi-account --path-metadata false --version-reporting false --output=$staging_dist_dir # Remove unnecessary output files echo "cd $staging_dist_dir" @@ -186,7 +218,8 @@ echo "-------------------------------------------------------------------------- # Move outputs from staging to template_dist_dir echo "Move outputs from staging to template_dist_dir" echo "cp $template_dir/*.template $template_dist_dir/" -cp $staging_dist_dir/*.template.json $template_dist_dir/ +cp $staging_dist_dir/mlops-workload-orchestrator-single-account.template.json $template_dist_dir/ +cp $staging_dist_dir/mlops-workload-orchestrator-multi-account.template.json $template_dist_dir/ rm *.template.json # Rename all *.template.json files to *.template @@ -215,11 +248,13 @@ echo "sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-single-a sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-single-account.template echo "sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-multi-account.template" sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-multi-account.template + replace="s/%%SOLUTION_NAME%%/$2/g" echo "sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-single-account" sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-single-account.template echo "sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-multi-account.template" sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-multi-account.template + replace="s/%%VERSION%%/$3/g" echo "sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-single-account.template" sed -i -e $replace $template_dist_dir/mlops-workload-orchestrator-single-account.template @@ -265,8 +300,10 @@ done echo "Creating zip files for the blueprint stacks" echo "mkdir -p $template_dir/bp_staging" mkdir -p $template_dir/bp_staging -echo "cp -r $source_dir/lib/blueprints $template_dir/bp_staging/" -cp -r $source_dir/lib/blueprints $template_dir/bp_staging/ +echo "cp -r $source_dir/infrastructure/lib/blueprints $template_dir/bp_staging/" +cp -r $source_dir/infrastructure/lib/blueprints $template_dir/bp_staging/ + + echo "cp -r $source_dir/lambdas/pipeline_orchestration/shared $template_dir/bp_staging/" cp -r $source_dir/lambdas/pipeline_orchestration/shared $template_dir/bp_staging/ @@ -277,41 +314,43 @@ rm -rf **/__pycache__/ rm -rf **/*.egg-info/ cd $template_dir/bp_staging/blueprints +# copy *.yaml templaes to the main blueprints folder +echo "cp -r $staging_dist_dir/*.yaml $template_dir/bp_staging/blueprints/" +cp -r $staging_dist_dir/*.yaml $template_dir/bp_staging/blueprints/ + # Loop through all blueprint directories in blueprints for bp in `find . -mindepth 1 -maxdepth 1 -type d`; do + echo "subdirector: $bp" + # # Loop through all subdirectories in blueprints/ + if [ $bp != "./lambdas" ]; then + # Remove any directory that is not 'lambdas' + rm -rf $bp + fi +done + +cd lambdas +# Loop through all lambda directories of the +for lambda in `find . -mindepth 1 -maxdepth 1 -type d`; do + + # Copying shared source codes to each lambda function + echo "cp -r $template_dir/bp_staging/shared $lambda" + cp -r $template_dir/bp_staging/shared $lambda + + # Removing './' from the directory name to use for zip file + echo "lambda_dir_name=`echo $lambda | cut -d '/' -f 2`" + lambda_dir_name=`echo $lambda | cut -d '/' -f 2` + + cd $lambda_dir_name + + # Creating the zip file for each lambda + echo "zip -r9 ../$lambda_dir_name.zip *" + zip -r9 ../$lambda_dir_name.zip * + cd .. - cd $bp - # Loop through all subdirectories in blueprints/ e.g., byom - for d in `find . -mindepth 1 -maxdepth 1 -type d`; do - if [ $d != "./lambdas" ]; then - # Remove any directory that is not 'lambdas' - rm -rf $d - fi - done - - cd lambdas - # Loop through all lambda directories of the - for lambda in `find . -mindepth 1 -maxdepth 1 -type d`; do - - # Copying shared source codes to each lambda function - echo "cp -r $template_dir/bp_staging/shared $lambda" - cp -r $template_dir/bp_staging/shared $lambda - - # Removing './' from the directory name to use for zip file - echo "lambda_dir_name=`echo $lambda | cut -d '/' -f 2`" - lambda_dir_name=`echo $lambda | cut -d '/' -f 2` - - cd $lambda_dir_name - - # Creating the zip file for each lambda - echo "zip -r9 ../$lambda_dir_name.zip *" - zip -r9 ../$lambda_dir_name.zip * - cd .. - - # Removing the lambda directories after creating zip files of them - echo "rm -rf $lambda" - rm -rf $lambda - done + # Removing the lambda directories after creating zip files of them + echo "rm -rf $lambda" + rm -rf $lambda + done cd $template_dir/bp_staging/blueprints @@ -319,6 +358,7 @@ cd $template_dir/bp_staging/blueprints rm -f *.py rm -f */*.py rm -f */*/*.py +rm -f */__pycache__ cd $template_dir/bp_staging diff --git a/deployment/cdk-solution-helper/index.js b/deployment/cdk-solution-helper/index.js index d33984f..3784ff2 100755 --- a/deployment/cdk-solution-helper/index.js +++ b/deployment/cdk-solution-helper/index.js @@ -1,5 +1,5 @@ /** - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * with the License. A copy of the License is located at @@ -12,47 +12,94 @@ */ // Imports -const fs = require('fs'); +const fs = require("fs"); // Paths const global_s3_assets = '../global-s3-assets'; +//this regular express also takes into account lambda functions defined in nested stacks +const _regex = /[\w]*AssetParameters/g; // NOSONAR: this regex is used only to clean the CloudFormation tempaltes + // For each template in global_s3_assets ... -fs.readdirSync(global_s3_assets).forEach(file => { - - // Import and parse template file - const raw_template = fs.readFileSync(`${global_s3_assets}/${file}`); - let template = JSON.parse(raw_template); - - // Clean-up Lambda function code dependencies - const resources = (template.Resources) ? template.Resources : {}; - const lambdaFunctions = Object.keys(resources).filter(function(key) { - return resources[key].Type === "AWS::Lambda::Function"; - }); - lambdaFunctions.forEach(function(f) { - const fn = template.Resources[f]; - // Set the S3 key reference - let artifactHash = Object.assign(fn.Properties.Code.S3Bucket.Ref); - artifactHash = artifactHash.replace('AssetParameters', ''); - artifactHash = artifactHash.substring(0, artifactHash.indexOf('S3Bucket')); - const assetPath = `asset${artifactHash}`; - fn.Properties.Code.S3Key = `%%SOLUTION_NAME%%/%%VERSION%%/${assetPath}.zip`; - // Set the S3 bucket reference - fn.Properties.Code.S3Bucket = { - 'Fn::Sub': '%%BUCKET_NAME%%-${AWS::Region}' - }; - }); - - // Clean-up parameters section - const parameters = (template.Parameters) ? template.Parameters : {}; - const assetParameters = Object.keys(parameters).filter(function(key) { - return key.includes('AssetParameters'); - }); - assetParameters.forEach(function(a) { - template.Parameters[a] = undefined; - }); - - // Output modified template file - const output_template = JSON.stringify(template, null, 2); - fs.writeFileSync(`${global_s3_assets}/${file}`, output_template); +fs.readdirSync(global_s3_assets).forEach((file) => { // NOSONAR: acceptable Cognitive Complexity + // Import and parse template file + const raw_template = fs.readFileSync(`${global_s3_assets}/${file}`); + let template = JSON.parse(raw_template); + + // Clean-up Lambda function code dependencies + const resources = template.Resources ? template.Resources : {}; + const lambdaFunctions = Object.keys(resources).filter(function (key) { + return resources[key].Type === "AWS::Lambda::Function"; + }); + + lambdaFunctions.forEach(function (f) { + const fn = template.Resources[f]; + let prop; + if (fn.Properties.hasOwnProperty("Code")) { + prop = fn.Properties.Code; + } else if (fn.Properties.hasOwnProperty("Content")) { + prop = fn.Properties.Content; + } + + if (prop.hasOwnProperty("S3Bucket")) { + // Set the S3 key reference + let artifactHash = Object.assign(prop.S3Key); + const assetPath = `asset${artifactHash}`; + prop.S3Key = `%%SOLUTION_NAME%%/%%VERSION%%/${assetPath}`; + + // Set the S3 bucket reference + prop.S3Bucket = { + "Fn::Sub": "%%BUCKET_NAME%%-${AWS::Region}", + }; + } else { + console.warn(`No S3Bucket Property found for ${JSON.stringify(prop)}`); + } + }); + + // Clean-up nested template stack dependencies + const nestedStacks = Object.keys(resources).filter(function (key) { + return resources[key].Type === "AWS::CloudFormation::Stack"; + }); + + nestedStacks.forEach(function (f) { + const fn = template.Resources[f]; + if (!fn.Metadata.hasOwnProperty("aws:asset:path")) { + throw new Error("Nested stack construct missing file name metadata"); + } + fn.Properties.TemplateURL = { + "Fn::Join": [ + "", + [ + "https://%%TEMPLATE_BUCKET_NAME%%.s3.", + { + Ref: "AWS::URLSuffix", + }, + "/", + `%%SOLUTION_NAME%%/%%VERSION%%/${fn.Metadata["aws:asset:path"].slice(0, -".json".length)}`, + ], + ], + }; + + const params = fn.Properties.Parameters ? fn.Properties.Parameters : {}; + const nestedStackParameters = Object.keys(params).filter(function (key) { + return key.search(_regex) > -1; + }); + + nestedStackParameters.forEach(function (stkParam) { + fn.Properties.Parameters[stkParam] = undefined; + }); + }); + + // Clean-up parameters section + const parameters = template.Parameters ? template.Parameters : {}; + const assetParameters = Object.keys(parameters).filter(function (key) { + return key.search(_regex) > -1; + }); + assetParameters.forEach(function (a) { + template.Parameters[a] = undefined; + }); + + // Output modified template file + const output_template = JSON.stringify(template, null, 2); + fs.writeFileSync(`${global_s3_assets}/${file}`, output_template); }); \ No newline at end of file diff --git a/deployment/cdk-solution-helper/package-lock.json b/deployment/cdk-solution-helper/package-lock.json index 41528d3..ed5c7b2 100644 --- a/deployment/cdk-solution-helper/package-lock.json +++ b/deployment/cdk-solution-helper/package-lock.json @@ -1,13 +1,28 @@ { "name": "cdk-solution-helper", "version": "0.1.0", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "name": "cdk-solution-helper", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "fs": "^0.0.2" + } + }, + "node_modules/fs": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.2.tgz", + "integrity": "sha1-4fJE7zkzwbKmS9R5kTYGDQ9ZFPg=" + } + }, "dependencies": { "fs": { - "version": "0.0.1-security", - "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", - "integrity": "sha1-invTcYa23d84E/I4WLV+yq9eQdQ=" + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.2.tgz", + "integrity": "sha1-4fJE7zkzwbKmS9R5kTYGDQ9ZFPg=" } } -} +} \ No newline at end of file diff --git a/deployment/cdk-solution-helper/package.json b/deployment/cdk-solution-helper/package.json index 89fac67..2ed7268 100755 --- a/deployment/cdk-solution-helper/package.json +++ b/deployment/cdk-solution-helper/package.json @@ -1,10 +1,13 @@ { "name": "cdk-solution-helper", "version": "0.1.0", - "devDependencies": { - "fs": "0.0.1-security" - }, + "description": "This script performs token replacement as part of the build pipeline", "dependencies": { - "fs": "0.0.1-security" + "fs": "^0.0.2" + }, + "license": "Apache-2.0", + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com/solutions" } } diff --git a/source/run-all-tests.sh b/deployment/run-all-tests.sh similarity index 84% rename from source/run-all-tests.sh rename to deployment/run-all-tests.sh index 07147be..d7c6e82 100755 --- a/source/run-all-tests.sh +++ b/deployment/run-all-tests.sh @@ -1,6 +1,6 @@ #!/bin/bash ###################################################################################################################### -# Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # # # # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # # with the License. A copy of the License is located at # @@ -30,12 +30,16 @@ setup_python_env() { python3 -m venv .venv-test echo "Initiating virtual environment" source .venv-test/bin/activate + echo "upgrading pip -> python3 -m pip install --upgrade pip" + python3 -m pip install --upgrade pip echo "Installing python packages" pip3 install -r requirements-test.txt + pip3 install -r requirements.txt echo "deactivate virtual environment" deactivate } + run_python_lambda_test() { lambda_name=$1 lambda_description=$2 @@ -62,9 +66,7 @@ run_python_test() { echo "[Test] Python path=$module_path module=$module_name" echo "------------------------------------------------------------------------------" - # setup coverage report path - mkdir -p $source_dir/test/coverage-reports - coverage_report_path=$source_dir/test/coverage-reports/$module_name.coverage.xml + coverage_report_path=$coverage_dir/$module_name.coverage.xml echo "coverage report path set to $coverage_report_path" # Use -vv for debugging @@ -93,20 +95,25 @@ run_javascript_lambda_test() { [ "${CLEAN:-true}" = "true" ] && rm -fr coverage } + run_cdk_project_test() { - component_description=$1 echo "------------------------------------------------------------------------------" - echo "[Test] $component_description" + echo "[Test] Running CDK tests" echo "------------------------------------------------------------------------------" - [ "${CLEAN:-true}" = "true" ] && npm run clean - npm install - npm run build - npm run test -- -u - if [ "$?" = "1" ]; then - echo "(source/run-all-tests.sh) ERROR: there is likely output above." 1>&2 - exit 1 - fi - [ "${CLEAN:-true}" = "true" ] && rm -fr coverage + + # Test the Lambda functions + cd $source_dir/infrastructure + + coverage_report_path=$coverage_dir/cdk.coverage.xml + echo "coverage report path set to $coverage_report_path" + + cd $source_dir/infrastructure + # Use -vv for debugging + python3 -m pytest --cov --cov-fail-under=80 --cov-report=term-missing --cov-report "xml:$coverage_report_path" + rm -rf *.egg-info + sed -i -e "s,$source_dir,source,g" $coverage_report_path + + } run_framework_lambda_test() { @@ -132,9 +139,10 @@ run_blueprint_lambda_test() { echo "------------------------------------------------------------------------------" echo "[Test] Run blueprint lambda unit tests" echo "------------------------------------------------------------------------------" - - cd $source_dir/lib/blueprints/byom/lambdas + + cd $source_dir/infrastructure/lib/blueprints/lambdas for folder in */ ; do + echo "$folder" cd "$folder" if [ "$folder" != "sagemaker_layer/" ]; then pip install -r requirements-test.txt @@ -149,6 +157,10 @@ run_blueprint_lambda_test() { source_dir=$PWD cd $source_dir +# setup coverage report directory +coverage_dir=$source_dir/test/coverage-reports +mkdir -p $coverage_dir + # Clean the test environment before running tests and after finished running tests # The variable is option with default of 'true'. It can be overwritten by caller # setting the CLEAN environment variable. For example @@ -162,6 +174,11 @@ setup_and_activate_python_env $source_dir python --version run_framework_lambda_test run_blueprint_lambda_test +run_cdk_project_test + +# deactive Python envn +deactivate + # Return to the source/ level where we started cd $source_dir \ No newline at end of file diff --git a/source/lib/__init__.py b/source/__init__.py similarity index 100% rename from source/lib/__init__.py rename to source/__init__.py diff --git a/source/app.py b/source/app.py deleted file mode 100644 index 776ed74..0000000 --- a/source/app.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -# ##################################################################################################################### -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # -# # -# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # -# with the License. A copy of the License is located at # -# # -# http://www.apache.org/licenses/LICENSE-2.0 # -# # -# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # -# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # -# and limitations under the License. # -# ##################################################################################################################### -from aws_cdk import core -from lib.mlops_orchestrator_stack import MLOpsStack -from lib.blueprints.byom.model_monitor import ModelMonitorStack -from lib.blueprints.byom.realtime_inference_pipeline import BYOMRealtimePipelineStack -from lib.blueprints.byom.byom_batch_pipeline import BYOMBatchStack -from lib.blueprints.byom.single_account_codepipeline import SingleAccountCodePipelineStack -from lib.blueprints.byom.multi_account_codepipeline import MultiAccountCodePipelineStack -from lib.blueprints.byom.byom_custom_algorithm_image_builder import BYOMCustomAlgorithmImageBuilderStack -from lib.blueprints.byom.autopilot_training_pipeline import AutopilotJobStack -from lib.blueprints.byom.model_training_pipeline import TrainingJobStack -from lib.aws_sdk_config_aspect import AwsSDKConfigAspect -from lib.protobuf_config_aspect import ProtobufConfigAspect -from lib.blueprints.byom.pipeline_definitions.cdk_context_value import get_cdk_context_value - -app = core.App() -solution_id = get_cdk_context_value(app, "SolutionId") -version = get_cdk_context_value(app, "Version") - -mlops_stack_single = MLOpsStack( - app, - "mlops-workload-orchestrator-single-account", - description=f"({solution_id}-sa) - MLOps Workload Orchestrator (Single Account Option). Version {version}", -) - -# add AWS_SDK_USER_AGENT env variable to Lambda functions -core.Aspects.of(mlops_stack_single).add(AwsSDKConfigAspect(app, "SDKUserAgentSingle", solution_id, version)) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(mlops_stack_single).add(ProtobufConfigAspect(app, "ProtobufConfigSingle")) - - -mlops_stack_multi = MLOpsStack( - app, - "mlops-workload-orchestrator-multi-account", - multi_account=True, - description=f"({solution_id}-ma) - MLOps Workload Orchestrator (Multi Account Option). Version {version}", -) - -core.Aspects.of(mlops_stack_multi).add(AwsSDKConfigAspect(app, "SDKUserAgentMulti", solution_id, version)) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(mlops_stack_multi).add(ProtobufConfigAspect(app, "ProtobufConfigMulti")) - -BYOMCustomAlgorithmImageBuilderStack( - app, - "BYOMCustomAlgorithmImageBuilderStack", - description=( - f"({solution_id}byom-caib) - Bring Your Own Model pipeline to build custom algorithm docker images" - f"in MLOps Workload Orchestrator. Version {version}" - ), -) - -batch_stack = BYOMBatchStack( - app, - "BYOMBatchStack", - description=( - f"({solution_id}byom-bt) - BYOM Batch Transform pipeline in MLOps Workload Orchestrator. Version {version}" - ), -) - -core.Aspects.of(batch_stack).add(AwsSDKConfigAspect(app, "SDKUserAgentBatch", solution_id, version)) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(batch_stack).add(ProtobufConfigAspect(app, "ProtobufConfigBatch")) - -data_quality_monitor_stack = ModelMonitorStack( - app, - "DataQualityModelMonitorStack", - monitoring_type="DataQuality", - description=(f"({solution_id}byom-dqmm) - DataQuality Model Monitor pipeline. Version {version}"), -) - -core.Aspects.of(data_quality_monitor_stack).add( - AwsSDKConfigAspect(app, "SDKUserAgentDataMonitor", solution_id, version) -) - - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(data_quality_monitor_stack).add(ProtobufConfigAspect(app, "ProtobufConfigDataMonitor")) - -model_quality_monitor_stack = ModelMonitorStack( - app, - "ModelQualityModelMonitorStack", - monitoring_type="ModelQuality", - description=(f"({solution_id}byom-mqmm) - ModelQuality Model Monitor pipeline. Version {version}"), -) - -core.Aspects.of(model_quality_monitor_stack).add( - AwsSDKConfigAspect(app, "SDKUserAgentModelQuality", solution_id, version) -) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(model_quality_monitor_stack).add(ProtobufConfigAspect(app, "ProtobufConfigModelQuality")) - -model_bias_monitor_stack = ModelMonitorStack( - app, - "ModelBiasModelMonitorStack", - monitoring_type="ModelBias", - description=(f"({solution_id}byom-mqmb) - ModelBias Model Monitor pipeline. Version {version}"), -) - -core.Aspects.of(model_bias_monitor_stack).add(AwsSDKConfigAspect(app, "SDKUserAgentModelBias", solution_id, version)) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(model_bias_monitor_stack).add(ProtobufConfigAspect(app, "ProtobufConfigModelBias")) - -model_explainability_monitor_stack = ModelMonitorStack( - app, - "ModelExplainabilityModelMonitorStack", - monitoring_type="ModelExplainability", - description=(f"({solution_id}byom-mqme) - ModelExplainability Model Monitor pipeline. Version {version}"), -) - -core.Aspects.of(model_explainability_monitor_stack).add( - AwsSDKConfigAspect(app, "SDKUserAgentModelExplainability", solution_id, version) -) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(model_explainability_monitor_stack).add(ProtobufConfigAspect(app, "ProtobufConfigModelExplainability")) - -realtime_stack = BYOMRealtimePipelineStack( - app, - "BYOMRealtimePipelineStack", - description=(f"({solution_id}byom-rip) - BYOM Realtime Inference Pipeline. Version {version}"), -) - -core.Aspects.of(realtime_stack).add(AwsSDKConfigAspect(app, "SDKUserAgentRealtime", solution_id, version)) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(realtime_stack).add(ProtobufConfigAspect(app, "ProtobufConfigRealtime")) - -autopilot_stack = AutopilotJobStack( - app, - "AutopilotJobStack", - description=(f"({solution_id}-autopilot) - Autopilot training pipeline. Version {version}"), -) - -core.Aspects.of(autopilot_stack).add(AwsSDKConfigAspect(app, "SDKUserAgentAutopilot", solution_id, version)) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(autopilot_stack).add(ProtobufConfigAspect(app, "ProtobufConfigAutopilot")) - -training_stack = TrainingJobStack( - app, - "TrainingJobStack", - training_type="TrainingJob", - description=(f"({solution_id}-training) - Model Training pipeline. Version {version}"), -) - -core.Aspects.of(training_stack).add(AwsSDKConfigAspect(app, "SDKUserAgentTraining", solution_id, version)) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(training_stack).add(ProtobufConfigAspect(app, "ProtobufConfigTraining")) - -hyperparameter_tunning_stack = TrainingJobStack( - app, - "HyperparamaterTunningJobStack", - training_type="HyperparameterTuningJob", - description=(f"({solution_id}-tuner) - Model Hyperparameter Tunning pipeline. Version {version}"), -) - -core.Aspects.of(hyperparameter_tunning_stack).add( - AwsSDKConfigAspect(app, "SDKUserAgentHyperparamater", solution_id, version) -) - -# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes -core.Aspects.of(hyperparameter_tunning_stack).add(ProtobufConfigAspect(app, "ProtobufConfigHyperparamater")) - -SingleAccountCodePipelineStack( - app, - "SingleAccountCodePipelineStack", - description=(f"({solution_id}byom-sac) - Single-account codepipeline. Version {version}"), -) - -MultiAccountCodePipelineStack( - app, - "MultiAccountCodePipelineStack", - description=(f"({solution_id}byom-mac) - Multi-account codepipeline. Version {version}"), -) - - -app.synth() diff --git a/source/infrastructure/.coveragerc b/source/infrastructure/.coveragerc new file mode 100644 index 0000000..faf65c3 --- /dev/null +++ b/source/infrastructure/.coveragerc @@ -0,0 +1,12 @@ +[run] +omit = + tests/* + setup.py + */.venv-test/* + cdk.out/* + conftest.py + test_*.py + app.py + lib/blueprints/lambdas/* +source = + . \ No newline at end of file diff --git a/source/lib/blueprints/__init__.py b/source/infrastructure/__init__.py similarity index 100% rename from source/lib/blueprints/__init__.py rename to source/infrastructure/__init__.py diff --git a/source/infrastructure/app.py b/source/infrastructure/app.py new file mode 100644 index 0000000..de06b57 --- /dev/null +++ b/source/infrastructure/app.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +from aws_cdk import App, Aspects, DefaultStackSynthesizer +from lib.mlops_orchestrator_stack import MLOpsStack +from lib.blueprints.ml_pipelines.model_monitor import ModelMonitorStack +from lib.blueprints.ml_pipelines.realtime_inference_pipeline import ( + BYOMRealtimePipelineStack, +) +from lib.blueprints.ml_pipelines.byom_batch_pipeline import BYOMBatchStack +from lib.blueprints.ml_pipelines.single_account_codepipeline import ( + SingleAccountCodePipelineStack, +) +from lib.blueprints.ml_pipelines.multi_account_codepipeline import ( + MultiAccountCodePipelineStack, +) +from lib.blueprints.ml_pipelines.byom_custom_algorithm_image_builder import ( + BYOMCustomAlgorithmImageBuilderStack, +) +from lib.blueprints.ml_pipelines.autopilot_training_pipeline import ( + AutopilotJobStack, +) +from lib.blueprints.ml_pipelines.model_training_pipeline import ( + TrainingJobStack, +) +from lib.blueprints.aspects.aws_sdk_config_aspect import AwsSDKConfigAspect +from lib.blueprints.aspects.protobuf_config_aspect import ProtobufConfigAspect +from lib.blueprints.aspects.app_registry_aspect import AppRegistry +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) + +app = App() +solution_id = get_cdk_context_value(app, "SolutionId") +solution_name = get_cdk_context_value(app, "SolutionName") +version = get_cdk_context_value(app, "Version") +app_registry_name = get_cdk_context_value(app, "AppRegistryName") +application_type = get_cdk_context_value(app, "ApplicationType") + +mlops_stack_single = MLOpsStack( + app, + "mlops-workload-orchestrator-single-account", + description=f"({solution_id}-sa) - MLOps Workload Orchestrator (Single Account Option). Version {version}", + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add app registry to single account stack +Aspects.of(mlops_stack_single).add( + AppRegistry( + mlops_stack_single, + "AppRegistrySingleAccount", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +# add AWS_SDK_USER_AGENT env variable to Lambda functions +Aspects.of(mlops_stack_single).add( + AwsSDKConfigAspect(app, "SDKUserAgentSingle", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(mlops_stack_single).add(ProtobufConfigAspect(app, "ProtobufConfigSingle")) + + +mlops_stack_multi = MLOpsStack( + app, + "mlops-workload-orchestrator-multi-account", + multi_account=True, + description=f"({solution_id}-ma) - MLOps Workload Orchestrator (Multi Account Option). Version {version}", + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to multi account stack +Aspects.of(mlops_stack_multi).add( + AppRegistry( + mlops_stack_multi, + "AppRegistryMultiAccount", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(mlops_stack_multi).add( + AwsSDKConfigAspect(app, "SDKUserAgentMulti", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(mlops_stack_multi).add(ProtobufConfigAspect(app, "ProtobufConfigMulti")) + +custom_image_builder = BYOMCustomAlgorithmImageBuilderStack( + app, + "BYOMCustomAlgorithmImageBuilderStack", + description=( + f"({solution_id}byom-caib) - Bring Your Own Model pipeline to build custom algorithm docker images" + f"in MLOps Workload Orchestrator. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to custom image builder +Aspects.of(custom_image_builder).add( + AppRegistry( + custom_image_builder, + "AppRegistryImageBuilder", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +batch_stack = BYOMBatchStack( + app, + "BYOMBatchStack", + description=( + f"({solution_id}byom-bt) - BYOM Batch Transform pipeline in MLOps Workload Orchestrator. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to batch transform stack +Aspects.of(batch_stack).add( + AppRegistry( + batch_stack, + "AppRegistryBatchTransform", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + + +Aspects.of(batch_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentBatch", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(batch_stack).add(ProtobufConfigAspect(app, "ProtobufConfigBatch")) + +data_quality_monitor_stack = ModelMonitorStack( + app, + "DataQualityModelMonitorStack", + monitoring_type="DataQuality", + description=( + f"({solution_id}byom-dqmm) - DataQuality Model Monitor pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to data quality stack +Aspects.of(data_quality_monitor_stack).add( + AppRegistry( + data_quality_monitor_stack, + "AppRegistryDataQuality", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(data_quality_monitor_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentDataMonitor", solution_id, version) +) + + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(data_quality_monitor_stack).add( + ProtobufConfigAspect(app, "ProtobufConfigDataMonitor") +) + +model_quality_monitor_stack = ModelMonitorStack( + app, + "ModelQualityModelMonitorStack", + monitoring_type="ModelQuality", + description=( + f"({solution_id}byom-mqmm) - ModelQuality Model Monitor pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to model quality stack +Aspects.of(model_quality_monitor_stack).add( + AppRegistry( + model_quality_monitor_stack, + "AppRegistryDataQuality", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(model_quality_monitor_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentModelQuality", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(model_quality_monitor_stack).add( + ProtobufConfigAspect(app, "ProtobufConfigModelQuality") +) + +model_bias_monitor_stack = ModelMonitorStack( + app, + "ModelBiasModelMonitorStack", + monitoring_type="ModelBias", + description=( + f"({solution_id}byom-mqmb) - ModelBias Model Monitor pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to model bias stack +Aspects.of(model_bias_monitor_stack).add( + AppRegistry( + model_bias_monitor_stack, + "AppRegistryModelBias", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(model_bias_monitor_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentModelBias", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(model_bias_monitor_stack).add( + ProtobufConfigAspect(app, "ProtobufConfigModelBias") +) + +model_explainability_monitor_stack = ModelMonitorStack( + app, + "ModelExplainabilityModelMonitorStack", + monitoring_type="ModelExplainability", + description=( + f"({solution_id}byom-mqme) - ModelExplainability Model Monitor pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to model bias stack +Aspects.of(model_explainability_monitor_stack).add( + AppRegistry( + model_explainability_monitor_stack, + "AppRegistryModelExplainability", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(model_explainability_monitor_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentModelExplainability", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(model_explainability_monitor_stack).add( + ProtobufConfigAspect(app, "ProtobufConfigModelExplainability") +) + +realtime_stack = BYOMRealtimePipelineStack( + app, + "BYOMRealtimePipelineStack", + description=( + f"({solution_id}byom-rip) - BYOM Realtime Inference Pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to realtime inference stack +Aspects.of(realtime_stack).add( + AppRegistry( + realtime_stack, + "AppRegistryRealtimeInference", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(realtime_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentRealtime", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(realtime_stack).add(ProtobufConfigAspect(app, "ProtobufConfigRealtime")) + +autopilot_stack = AutopilotJobStack( + app, + "AutopilotJobStack", + description=( + f"({solution_id}-autopilot) - Autopilot training pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to autopilot stack +Aspects.of(autopilot_stack).add( + AppRegistry( + autopilot_stack, + "AppRegistryAutopilot", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(autopilot_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentAutopilot", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(autopilot_stack).add(ProtobufConfigAspect(app, "ProtobufConfigAutopilot")) + +training_stack = TrainingJobStack( + app, + "TrainingJobStack", + training_type="TrainingJob", + description=( + f"({solution_id}-training) - Model Training pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to model training stack +Aspects.of(training_stack).add( + AppRegistry( + training_stack, + "AppRegistryModelTraining", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +Aspects.of(training_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentTraining", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(training_stack).add(ProtobufConfigAspect(app, "ProtobufConfigTraining")) + +hyperparameter_tunning_stack = TrainingJobStack( + app, + "HyperparamaterTunningJobStack", + training_type="HyperparameterTuningJob", + description=( + f"({solution_id}-tuner) - Model Hyperparameter Tunning pipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to hyperparamater training stack +Aspects.of(hyperparameter_tunning_stack).add( + AppRegistry( + hyperparameter_tunning_stack, + "AppRegistryTuner", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + + +Aspects.of(hyperparameter_tunning_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentHyperparamater", solution_id, version) +) + +# add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes +Aspects.of(hyperparameter_tunning_stack).add( + ProtobufConfigAspect(app, "ProtobufConfigHyperparamater") +) + +single_account_codepipeline = SingleAccountCodePipelineStack( + app, + "SingleAccountCodePipelineStack", + description=( + f"({solution_id}byom-sac) - Single-account codepipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to single account single_account_codepipelinecodepipeline stack +Aspects.of(single_account_codepipeline).add( + AppRegistry( + single_account_codepipeline, + "AppRegistrySingleCodepipeline", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +multi_account_codepipeline = MultiAccountCodePipelineStack( + app, + "MultiAccountCodePipelineStack", + description=( + f"({solution_id}byom-mac) - Multi-account codepipeline. Version {version}" + ), + synthesizer=DefaultStackSynthesizer(generate_bootstrap_version_rule=False), +) + +# add AppRegistry to single account single_account_codepipelinecodepipeline stack +Aspects.of(multi_account_codepipeline).add( + AppRegistry( + multi_account_codepipeline, + "AppRegistryMultiCodepipeline", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) +) + +app.synth() diff --git a/source/cdk.json b/source/infrastructure/cdk.json similarity index 57% rename from source/cdk.json rename to source/infrastructure/cdk.json index bfe9b6f..e6d137f 100644 --- a/source/cdk.json +++ b/source/infrastructure/cdk.json @@ -1,12 +1,13 @@ { "app": "python3 app.py", "context": { - "@aws-cdk/core:enableStackNameDuplicates": "true", - "aws-cdk:enableDiffNoFail": "true", "SolutionId": "SO0136", - "SolutionName":"%%SOLUTION_NAME%%", + "SolutionName": "%%SOLUTION_NAME%%", "Version": "%%VERSION%%", + "AppRegistryName": "mlops", + "ApplicationType": "AWS-Solutions", "SourceBucket": "%%BUCKET_NAME%%", "BlueprintsFile": "blueprints.zip" + } -} +} \ No newline at end of file diff --git a/source/lib/blueprints/byom/__init__.py b/source/infrastructure/lib/__init__.py similarity index 100% rename from source/lib/blueprints/byom/__init__.py rename to source/infrastructure/lib/__init__.py diff --git a/source/lib/blueprints/byom/.gitignore b/source/infrastructure/lib/blueprints/.gitignore similarity index 100% rename from source/lib/blueprints/byom/.gitignore rename to source/infrastructure/lib/blueprints/.gitignore diff --git a/source/lib/blueprints/byom/lambdas/__init__.py b/source/infrastructure/lib/blueprints/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/__init__.py rename to source/infrastructure/lib/blueprints/__init__.py diff --git a/source/infrastructure/lib/blueprints/aspects/app_registry_aspect.py b/source/infrastructure/lib/blueprints/aspects/app_registry_aspect.py new file mode 100644 index 0000000..e179a18 --- /dev/null +++ b/source/infrastructure/lib/blueprints/aspects/app_registry_aspect.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed # +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for # +# the specific language governing permissions and limitations under the License. # +# ##################################################################################################################### + +import jsii + +import aws_cdk as cdk +from aws_cdk import aws_servicecatalogappregistry_alpha as appreg + +from constructs import Construct, IConstruct + + +@jsii.implements(cdk.IAspect) +class AppRegistry(Construct): + """This construct creates the resources required for AppRegistry and injects them as Aspects""" + + def __init__( + self, + scope: Construct, + id: str, + solution_id: str, + solution_name: str, + solution_version: str, + app_registry_name: str, + application_type: str, + ): + super().__init__(scope, id) + self.solution_id = solution_id + self.solution_name = solution_name + self.solution_version = solution_version + self.app_registry_name = app_registry_name + self.application_type = application_type + self.application: appreg.Application = None + + def visit(self, node: IConstruct) -> None: + """The visitor method invoked during cdk synthesis""" + if isinstance(node, cdk.Stack): + if not node.nested: + # parent stack + stack: cdk.Stack = node + self.__create_app_for_app_registry() + self.application.associate_stack(stack) + self.__create_atttribute_group() + self.__add_tags_for_application() + else: + # nested stack + if not self.application: + self.__create_app_for_app_registry() + + self.application.associate_stack(node) + + def __create_app_for_app_registry(self) -> None: + """Method to create an AppRegistry Application""" + self.application = appreg.Application( + self, + "RegistrySetup", + application_name=cdk.Fn.join( + "-", + [ + "App", + cdk.Aws.STACK_NAME, + self.app_registry_name, + ], + ), + description=f"Service Catalog application to track and manage all your resources for the solution {self.solution_name}", + ) + + def __add_tags_for_application(self) -> None: + """Method to add tags to the AppRegistry's Application instance""" + if not self.application: + self.__create_app_for_app_registry() + + cdk.Tags.of(self.application).add("Solutions:SolutionID", self.solution_id) + cdk.Tags.of(self.application).add("Solutions:SolutionName", self.solution_name) + cdk.Tags.of(self.application).add( + "Solutions:SolutionVersion", self.solution_version + ) + cdk.Tags.of(self.application).add( + "Solutions:ApplicationType", self.application_type + ) + + def __create_atttribute_group(self) -> None: + """Method to add attributes to be as associated with the Application's instance in AppRegistry""" + if not self.application: + self.__create_app_for_app_registry() + + self.application.associate_attribute_group( + appreg.AttributeGroup( + self, + "AppAttributes", + attribute_group_name=f"AttrGrp-{cdk.Aws.STACK_NAME}", + description="Attributes for Solutions Metadata", + attributes={ + "applicationType": self.application_type, + "version": self.solution_version, + "solutionID": self.solution_id, + "solutionName": self.solution_name, + }, + ) + ) diff --git a/source/lib/aws_sdk_config_aspect.py b/source/infrastructure/lib/blueprints/aspects/aws_sdk_config_aspect.py similarity index 90% rename from source/lib/aws_sdk_config_aspect.py rename to source/infrastructure/lib/blueprints/aspects/aws_sdk_config_aspect.py index 994f28d..301006f 100644 --- a/source/lib/aws_sdk_config_aspect.py +++ b/source/infrastructure/lib/blueprints/aspects/aws_sdk_config_aspect.py @@ -12,7 +12,8 @@ # ##################################################################################################################### import jsii import json -from aws_cdk.core import IAspect, IConstruct, Construct +from constructs import IConstruct, Construct +from aws_cdk import IAspect from aws_cdk.aws_lambda import Function @@ -25,5 +26,7 @@ def __init__(self, scope: Construct, id: str, solution_id: str, version: str): def visit(self, node: IConstruct): if isinstance(node, Function): - user_agent = json.dumps({"user_agent_extra": f"AwsSolution/{self.solution_id}/{self.version}"}) + user_agent = json.dumps( + {"user_agent_extra": f"AwsSolution/{self.solution_id}/{self.version}"} + ) node.add_environment(key="AWS_SDK_USER_AGENT", value=user_agent) diff --git a/source/lib/conditional_resource.py b/source/infrastructure/lib/blueprints/aspects/conditional_resource.py similarity index 90% rename from source/lib/conditional_resource.py rename to source/infrastructure/lib/blueprints/aspects/conditional_resource.py index 24cbf79..a11b141 100644 --- a/source/lib/conditional_resource.py +++ b/source/infrastructure/lib/blueprints/aspects/conditional_resource.py @@ -1,5 +1,5 @@ # ##################################################################################################################### -# Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # # # # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # # with the License. A copy of the License is located at # @@ -11,7 +11,8 @@ # and limitations under the License. # # ##################################################################################################################### import jsii -from aws_cdk.core import CfnCondition, CfnResource, IAspect, IConstruct +from aws_cdk import CfnCondition, CfnResource, IAspect +from constructs import IConstruct # This code enables `apply_aspect()` to apply conditions to a resource. # This way we can provision some resources if a condition is true. diff --git a/source/lib/protobuf_config_aspect.py b/source/infrastructure/lib/blueprints/aspects/protobuf_config_aspect.py similarity index 90% rename from source/lib/protobuf_config_aspect.py rename to source/infrastructure/lib/blueprints/aspects/protobuf_config_aspect.py index 393bca0..98b441a 100644 --- a/source/lib/protobuf_config_aspect.py +++ b/source/infrastructure/lib/blueprints/aspects/protobuf_config_aspect.py @@ -11,7 +11,8 @@ # and limitations under the License. # # ##################################################################################################################### import jsii -from aws_cdk.core import IAspect, IConstruct, Construct +from constructs import IConstruct, Construct +from aws_cdk import IAspect from aws_cdk.aws_lambda import Function @@ -23,4 +24,6 @@ def __init__(self, scope: Construct, id: str): def visit(self, node: IConstruct): if isinstance(node, Function): # this is to handle the protobuf package breaking changes. - node.add_environment(key="PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", value="python") + node.add_environment( + key="PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", value="python" + ) diff --git a/source/lib/blueprints/byom/lambdas/batch_transform/tests/__init__.py b/source/infrastructure/lib/blueprints/lambdas/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/batch_transform/tests/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/batch_transform/.coveragerc b/source/infrastructure/lib/blueprints/lambdas/batch_transform/.coveragerc similarity index 100% rename from source/lib/blueprints/byom/lambdas/batch_transform/.coveragerc rename to source/infrastructure/lib/blueprints/lambdas/batch_transform/.coveragerc diff --git a/source/lib/blueprints/byom/lambdas/batch_transform/main.py b/source/infrastructure/lib/blueprints/lambdas/batch_transform/main.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/batch_transform/main.py rename to source/infrastructure/lib/blueprints/lambdas/batch_transform/main.py diff --git a/source/lib/blueprints/byom/lambdas/batch_transform/requirements-test.txt b/source/infrastructure/lib/blueprints/lambdas/batch_transform/requirements-test.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/batch_transform/requirements-test.txt rename to source/infrastructure/lib/blueprints/lambdas/batch_transform/requirements-test.txt diff --git a/source/lib/blueprints/byom/lambdas/batch_transform/setup.py b/source/infrastructure/lib/blueprints/lambdas/batch_transform/setup.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/batch_transform/setup.py rename to source/infrastructure/lib/blueprints/lambdas/batch_transform/setup.py diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/tests/__init__.py b/source/infrastructure/lib/blueprints/lambdas/batch_transform/tests/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/tests/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/batch_transform/tests/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/batch_transform/tests/test_batch_transform.py b/source/infrastructure/lib/blueprints/lambdas/batch_transform/tests/test_batch_transform.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/batch_transform/tests/test_batch_transform.py rename to source/infrastructure/lib/blueprints/lambdas/batch_transform/tests/test_batch_transform.py diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/.coveragerc b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/.coveragerc similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/.coveragerc rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/.coveragerc diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/baselines_helper.py b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/baselines_helper.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/baselines_helper.py rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/baselines_helper.py diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/main.py b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/main.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/main.py rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/main.py diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/requirements-test.txt b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/requirements-test.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/requirements-test.txt rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/requirements-test.txt diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/setup.py b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/setup.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/setup.py rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/setup.py diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/__init__.py b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/tests/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/tests/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/tests/fixtures/baseline_fixtures.py b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/tests/fixtures/baseline_fixtures.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/tests/fixtures/baseline_fixtures.py rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/tests/fixtures/baseline_fixtures.py diff --git a/source/lib/blueprints/byom/lambdas/create_baseline_job/tests/test_create_data_baseline.py b/source/infrastructure/lib/blueprints/lambdas/create_baseline_job/tests/test_create_data_baseline.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_baseline_job/tests/test_create_data_baseline.py rename to source/infrastructure/lib/blueprints/lambdas/create_baseline_job/tests/test_create_data_baseline.py diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/.coveragerc b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/.coveragerc similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/.coveragerc rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/.coveragerc diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/tests/__init__.py b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/tests/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/main.py b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/main.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/main.py rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/main.py diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/model_training_helper.py b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/model_training_helper.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/model_training_helper.py rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/model_training_helper.py diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/requirements-test.txt b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/requirements-test.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/requirements-test.txt rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/requirements-test.txt diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/setup.py b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/setup.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/setup.py rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/setup.py diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/__init__.py b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/tests/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/tests/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/tests/fixtures/training_fixtures.py b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/tests/fixtures/training_fixtures.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/tests/fixtures/training_fixtures.py rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/tests/fixtures/training_fixtures.py diff --git a/source/lib/blueprints/byom/lambdas/create_model_training_job/tests/test_create_model_training.py b/source/infrastructure/lib/blueprints/lambdas/create_model_training_job/tests/test_create_model_training.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_model_training_job/tests/test_create_model_training.py rename to source/infrastructure/lib/blueprints/lambdas/create_model_training_job/tests/test_create_model_training.py diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/.coveragerc b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/.coveragerc similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/.coveragerc rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/.coveragerc diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/tests/__init__.py b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/tests/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/main.py b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/main.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/main.py rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/main.py diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/requirements-test.txt b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/requirements-test.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/requirements-test.txt rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/requirements-test.txt diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/setup.py b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/setup.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/setup.py rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/setup.py diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/tests/__init__.py b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/tests/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/tests/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/tests/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/tests/fixtures/autopilot_fixtures.py b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/tests/fixtures/autopilot_fixtures.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/tests/fixtures/autopilot_fixtures.py rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/tests/fixtures/autopilot_fixtures.py diff --git a/source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/tests/test_create_autopilot_job.py b/source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/tests/test_create_autopilot_job.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_sagemaker_autopilot_job/tests/test_create_autopilot_job.py rename to source/infrastructure/lib/blueprints/lambdas/create_sagemaker_autopilot_job/tests/test_create_autopilot_job.py diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/.coveragerc b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/.coveragerc similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/.coveragerc rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/.coveragerc diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/main.py b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/main.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/main.py rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/main.py diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/requirements-test.txt b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/requirements-test.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/requirements-test.txt rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/requirements-test.txt diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/setup.py b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/setup.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/setup.py rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/setup.py diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/stackset_helpers.py b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/stackset_helpers.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/stackset_helpers.py rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/stackset_helpers.py diff --git a/source/lib/blueprints/byom/lambdas/inference/tests/__init__.py b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/tests/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/inference/tests/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/tests/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/tests/fixtures/stackset_fixtures.py b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/tests/fixtures/stackset_fixtures.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/tests/fixtures/stackset_fixtures.py rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/tests/fixtures/stackset_fixtures.py diff --git a/source/lib/blueprints/byom/lambdas/create_update_cf_stackset/tests/test_create_update_cf_stackset.py b/source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/tests/test_create_update_cf_stackset.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/create_update_cf_stackset/tests/test_create_update_cf_stackset.py rename to source/infrastructure/lib/blueprints/lambdas/create_update_cf_stackset/tests/test_create_update_cf_stackset.py diff --git a/source/lib/blueprints/byom/lambdas/inference/.coveragerc b/source/infrastructure/lib/blueprints/lambdas/inference/.coveragerc similarity index 100% rename from source/lib/blueprints/byom/lambdas/inference/.coveragerc rename to source/infrastructure/lib/blueprints/lambdas/inference/.coveragerc diff --git a/source/lib/blueprints/byom/lambdas/inference/main.py b/source/infrastructure/lib/blueprints/lambdas/inference/main.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/inference/main.py rename to source/infrastructure/lib/blueprints/lambdas/inference/main.py diff --git a/source/lib/blueprints/byom/lambdas/inference/requirements-test.txt b/source/infrastructure/lib/blueprints/lambdas/inference/requirements-test.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/inference/requirements-test.txt rename to source/infrastructure/lib/blueprints/lambdas/inference/requirements-test.txt diff --git a/source/lib/blueprints/byom/lambdas/inference/setup.py b/source/infrastructure/lib/blueprints/lambdas/inference/setup.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/inference/setup.py rename to source/infrastructure/lib/blueprints/lambdas/inference/setup.py diff --git a/source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/tests/__init__.py b/source/infrastructure/lib/blueprints/lambdas/inference/tests/__init__.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/tests/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/inference/tests/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/inference/tests/test_inference.py b/source/infrastructure/lib/blueprints/lambdas/inference/tests/test_inference.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/inference/tests/test_inference.py rename to source/infrastructure/lib/blueprints/lambdas/inference/tests/test_inference.py diff --git a/source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/.coveragerc b/source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/.coveragerc similarity index 100% rename from source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/.coveragerc rename to source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/.coveragerc diff --git a/source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/index.py b/source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/index.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/index.py rename to source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/index.py diff --git a/source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/requirements-test.txt b/source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/requirements-test.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/requirements-test.txt rename to source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/requirements-test.txt diff --git a/source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/requirements.txt b/source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/requirements.txt similarity index 100% rename from source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/requirements.txt rename to source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/requirements.txt diff --git a/source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/setup.py b/source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/setup.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/setup.py rename to source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/setup.py diff --git a/source/lib/blueprints/byom/pipeline_definitions/__init__.py b/source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/tests/__init__.py similarity index 100% rename from source/lib/blueprints/byom/pipeline_definitions/__init__.py rename to source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/tests/__init__.py diff --git a/source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/tests/test_invoke_lambda.py b/source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/tests/test_invoke_lambda.py similarity index 100% rename from source/lib/blueprints/byom/lambdas/invoke_lambda_custom_resource/tests/test_invoke_lambda.py rename to source/infrastructure/lib/blueprints/lambdas/invoke_lambda_custom_resource/tests/test_invoke_lambda.py diff --git a/source/infrastructure/lib/blueprints/lambdas/sagemaker_layer/requirements.txt b/source/infrastructure/lib/blueprints/lambdas/sagemaker_layer/requirements.txt new file mode 100644 index 0000000..a2e6560 --- /dev/null +++ b/source/infrastructure/lib/blueprints/lambdas/sagemaker_layer/requirements.txt @@ -0,0 +1,3 @@ +botocore==1.29.155 +boto3==1.26.155 +sagemaker==2.165.0 diff --git a/source/infrastructure/lib/blueprints/ml_pipelines/__init__.py b/source/infrastructure/lib/blueprints/ml_pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/lib/blueprints/byom/autopilot_training_pipeline.py b/source/infrastructure/lib/blueprints/ml_pipelines/autopilot_training_pipeline.py similarity index 89% rename from source/lib/blueprints/byom/autopilot_training_pipeline.py rename to source/infrastructure/lib/blueprints/ml_pipelines/autopilot_training_pipeline.py index 3eca8ec..cf6044d 100644 --- a/source/lib/blueprints/byom/autopilot_training_pipeline.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/autopilot_training_pipeline.py @@ -10,27 +10,31 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### +from constructs import Construct from aws_cdk import ( + Stack, + Aws, + Fn, + CfnOutput, aws_s3 as s3, aws_events as events, aws_sns as sns, - core, ) -from lib.blueprints.byom.pipeline_definitions.deploy_actions import ( +from lib.blueprints.pipeline_definitions.deploy_actions import ( autopilot_training_job, sagemaker_layer, create_invoke_lambda_custom_resource, eventbridge_rule_to_sns, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ( +from lib.blueprints.pipeline_definitions.templates_parameters import ( ParameteresFactory as pf, ConditionsFactory as cf, ) -class AutopilotJobStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: +class AutopilotJobStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: super().__init__(scope, id, **kwargs) # Parameteres # @@ -44,10 +48,16 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: self.job_output_location = pf.create_job_output_location_parameter(self) self.compression_type = pf.create_compression_type_parameter(self) self.max_candidates = pf.create_max_candidates_parameter(self) - self.encrypt_inter_container_traffic = pf.create_encrypt_inner_traffic_parameter(self) - self.max_runtime_per_training_job_in_seconds = pf.create_max_runtime_per_job_parameter(self) + self.encrypt_inter_container_traffic = ( + pf.create_encrypt_inner_traffic_parameter(self) + ) + self.max_runtime_per_training_job_in_seconds = ( + pf.create_max_runtime_per_job_parameter(self) + ) self.total_job_runtime_in_seconds = pf.create_total_job_runtime_parameter(self) - self.generate_candidate_definitions_only = pf.create_generate_definitions_only_parameter(self) + self.generate_candidate_definitions_only = ( + pf.create_generate_definitions_only_parameter(self) + ) self.kms_key_arn = pf.create_kms_key_arn_parameter(self) self.mlops_sns_topic_arn = pf.create_sns_topic_arn_parameter(self) @@ -61,7 +71,9 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: self.compression_type_provided = cf.create_attribute_provided_condition( self, "CompressionTypeProvided", self.compression_type ) - self.kms_key_arn_provided = cf.create_attribute_provided_condition(self, "KMSProvided", self.kms_key_arn) + self.kms_key_arn_provided = cf.create_attribute_provided_condition( + self, "KMSProvided", self.kms_key_arn + ) # Resources # self.assets_bucket = s3.Bucket.from_bucket_name( @@ -82,7 +94,8 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: # create custom resource to invoke the autopilot job lambda invoke_lambda_custom_resource = self._create_invoke_lambda_custom_resource( - function_name=autopilot_lambda.function_name, function_arn=autopilot_lambda.function_arn + function_name=autopilot_lambda.function_name, + function_arn=autopilot_lambda.function_arn, ) # create dependency on the autopilot lambda @@ -108,18 +121,26 @@ def _create_autoplot_lambda(self): training_data=self.training_data.value_as_string, target_attribute_name=self.target_attribute_name.value_as_string, job_output_location=self.job_output_location.value_as_string, - problem_type=core.Fn.condition_if( - self.problem_type_provided.logical_id, self.problem_type.value_as_string, core.Aws.NO_VALUE + problem_type=Fn.condition_if( + self.problem_type_provided.logical_id, + self.problem_type.value_as_string, + Aws.NO_VALUE, ).to_string(), - job_objective=core.Fn.condition_if( - self.job_objective_provided.logical_id, self.job_objective.value_as_string, core.Aws.NO_VALUE + job_objective=Fn.condition_if( + self.job_objective_provided.logical_id, + self.job_objective.value_as_string, + Aws.NO_VALUE, ).to_string(), - compression_type=core.Fn.condition_if( - self.compression_type_provided.logical_id, self.compression_type.value_as_string, core.Aws.NO_VALUE + compression_type=Fn.condition_if( + self.compression_type_provided.logical_id, + self.compression_type.value_as_string, + Aws.NO_VALUE, ).to_string(), max_candidates=self.max_candidates.value_as_string, - kms_key_arn=core.Fn.condition_if( - self.kms_key_arn_provided.logical_id, self.kms_key_arn.value_as_string, core.Aws.NO_VALUE + kms_key_arn=Fn.condition_if( + self.kms_key_arn_provided.logical_id, + self.kms_key_arn.value_as_string, + Aws.NO_VALUE, ).to_string(), encrypt_inter_container_traffic=self.encrypt_inter_container_traffic.value_as_string, max_runtime_per_training_job_in_seconds=self.max_runtime_per_training_job_in_seconds.value_as_string, @@ -176,7 +197,9 @@ def _create_job_notification_rules(self): source=event_source, detail_type=["SageMaker HyperParameter Tuning Job State Change"], detail={ - "HyperParameterTuningJobName": [{"prefix": self.job_name.value_as_string}], + "HyperParameterTuningJobName": [ + {"prefix": self.job_name.value_as_string} + ], "HyperParameterTuningJobStatus": ["Completed", "Failed", "Stopped"], }, target_sns_topic=self.job_notification_topic, @@ -292,19 +315,19 @@ def _create_job_notification_rules(self): ) def _create_stack_outputs(self): - core.CfnOutput( + CfnOutput( self, id="AutopilotJobName", value=self.job_name.value_as_string, description="The autopilot training job's name", ) - core.CfnOutput( + CfnOutput( self, id="AutopilotJobOutputLocation", value=f"https://s3.console.aws.amazon.com/s3/buckets/{self.assets_bucket_name.value_as_string}/{self.job_output_location.value_as_string}/", description="Output location of the autopilot training job", ) - core.CfnOutput( + CfnOutput( self, id="TrainingDataLocation", value=f"https://s3.console.aws.amazon.com/s3/buckets/{self.assets_bucket_name.value_as_string}/{self.training_data.value_as_string}", diff --git a/source/lib/blueprints/byom/byom_batch_pipeline.py b/source/infrastructure/lib/blueprints/ml_pipelines/byom_batch_pipeline.py similarity index 84% rename from source/lib/blueprints/byom/byom_batch_pipeline.py rename to source/infrastructure/lib/blueprints/ml_pipelines/byom_batch_pipeline.py index 8b3a3e7..39477bb 100644 --- a/source/lib/blueprints/byom/byom_batch_pipeline.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/byom_batch_pipeline.py @@ -10,31 +10,35 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import ( - aws_s3 as s3, - core, -) -from lib.blueprints.byom.pipeline_definitions.deploy_actions import ( +from constructs import Construct +from aws_cdk import Stack, Aws, Fn, CfnOutput, aws_s3 as s3 +from lib.blueprints.pipeline_definitions.deploy_actions import ( batch_transform, sagemaker_layer, create_invoke_lambda_custom_resource, ) -from lib.blueprints.byom.pipeline_definitions.sagemaker_role import create_sagemaker_role -from lib.blueprints.byom.pipeline_definitions.sagemaker_model import create_sagemaker_model -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ( +from lib.blueprints.pipeline_definitions.sagemaker_role import ( + create_sagemaker_role, +) +from lib.blueprints.pipeline_definitions.sagemaker_model import ( + create_sagemaker_model, +) +from lib.blueprints.pipeline_definitions.templates_parameters import ( ParameteresFactory as pf, ConditionsFactory as cf, ) -class BYOMBatchStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: +class BYOMBatchStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: super().__init__(scope, id, **kwargs) # Parameteres # blueprint_bucket_name = pf.create_blueprint_bucket_name_parameter(self) assets_bucket_name = pf.create_assets_bucket_name_parameter(self) - custom_algorithms_ecr_repo_arn = pf.create_custom_algorithms_ecr_repo_arn_parameter(self) + custom_algorithms_ecr_repo_arn = ( + pf.create_custom_algorithms_ecr_repo_arn_parameter(self) + ) kms_key_arn = pf.create_kms_key_arn_parameter(self) algorithm_image_uri = pf.create_algorithm_image_uri_parameter(self) model_name = pf.create_model_name_parameter(self) @@ -47,14 +51,22 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: model_package_name = pf.create_model_package_name_parameter(self) # Conditions - custom_algorithms_ecr_repo_arn_provided = cf.create_custom_algorithms_ecr_repo_arn_provided_condition( - self, custom_algorithms_ecr_repo_arn + custom_algorithms_ecr_repo_arn_provided = ( + cf.create_custom_algorithms_ecr_repo_arn_provided_condition( + self, custom_algorithms_ecr_repo_arn + ) + ) + kms_key_arn_provided = cf.create_kms_key_arn_provided_condition( + self, kms_key_arn + ) + model_registry_provided = cf.create_model_registry_provided_condition( + self, model_package_name ) - kms_key_arn_provided = cf.create_kms_key_arn_provided_condition(self, kms_key_arn) - model_registry_provided = cf.create_model_registry_provided_condition(self, model_package_name) # Resources # - assets_bucket = s3.Bucket.from_bucket_name(self, "ImportedAssetsBucket", assets_bucket_name.value_as_string) + assets_bucket = s3.Bucket.from_bucket_name( + self, "ImportedAssetsBucket", assets_bucket_name.value_as_string + ) # getting blueprint bucket object from its name - will be used later in the stack blueprint_bucket = s3.Bucket.from_bucket_name( self, "ImportedBlueprintBucket", blueprint_bucket_name.value_as_string @@ -102,8 +114,10 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: batch_input_bucket.value_as_string, batch_inference_data.value_as_string, batch_job_output_location.value_as_string, - core.Fn.condition_if( - kms_key_arn_provided.logical_id, kms_key_arn.value_as_string, core.Aws.NO_VALUE + Fn.condition_if( + kms_key_arn_provided.logical_id, + kms_key_arn.value_as_string, + Aws.NO_VALUE, ).to_string(), sm_layer, ) @@ -133,21 +147,21 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: invoke_lambda_custom_resource.node.add_dependency(batch_transform_lambda) - core.CfnOutput( + CfnOutput( self, id="SageMakerModelName", value=sagemaker_model.attr_model_name, description="The name of the SageMaker model used by the batch transform job", ) - core.CfnOutput( + CfnOutput( self, id="BatchTransformJobName", value=f"{sagemaker_model.attr_model_name}-batch-transform-*", description="The name of the SageMaker batch transform job", ) - core.CfnOutput( + CfnOutput( self, id="BatchTransformOutputLocation", value=f"https://s3.console.aws.amazon.com/s3/buckets/{batch_job_output_location.value_as_string}/", diff --git a/source/lib/blueprints/byom/byom_custom_algorithm_image_builder.py b/source/infrastructure/lib/blueprints/ml_pipelines/byom_custom_algorithm_image_builder.py similarity index 75% rename from source/lib/blueprints/byom/byom_custom_algorithm_image_builder.py rename to source/infrastructure/lib/blueprints/ml_pipelines/byom_custom_algorithm_image_builder.py index e1f18e1..8cbbac3 100644 --- a/source/lib/blueprints/byom/byom_custom_algorithm_image_builder.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/byom_custom_algorithm_image_builder.py @@ -10,27 +10,32 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### +from constructs import Construct from aws_cdk import ( + Stack, + Aws, + CfnOutput, aws_iam as iam, aws_s3 as s3, aws_sns as sns, aws_events_targets as targets, aws_events as events, aws_codepipeline as codepipeline, - core, ) -from lib.blueprints.byom.pipeline_definitions.source_actions import source_action_custom -from lib.blueprints.byom.pipeline_definitions.build_actions import build_action -from lib.blueprints.byom.pipeline_definitions.helpers import ( +from lib.blueprints.pipeline_definitions.source_actions import source_action_custom +from lib.blueprints.pipeline_definitions.build_actions import build_action +from lib.blueprints.pipeline_definitions.helpers import ( pipeline_permissions, suppress_pipeline_bucket, suppress_iam_complex, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ParameteresFactory as pf +from lib.blueprints.pipeline_definitions.templates_parameters import ( + ParameteresFactory as pf, +) -class BYOMCustomAlgorithmImageBuilderStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: +class BYOMCustomAlgorithmImageBuilderStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: super().__init__(scope, id, **kwargs) # Parameteres # @@ -41,25 +46,38 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: mlops_sns_topic_arn = pf.create_sns_topic_arn_parameter(self) # Resources # - assets_bucket = s3.Bucket.from_bucket_name(self, "ImportedAssetsBucket", assets_bucket_name.value_as_string) + assets_bucket = s3.Bucket.from_bucket_name( + self, "ImportedAssetsBucket", assets_bucket_name.value_as_string + ) # Defining pipeline stages # source stage - source_output, source_action_definition = source_action_custom(assets_bucket, custom_container) + source_output, source_action_definition = source_action_custom( + assets_bucket, custom_container + ) # build stage build_action_definition, container_uri = build_action( - self, ecr_repo_name.value_as_string, image_tag.value_as_string, source_output + self, + ecr_repo_name.value_as_string, + image_tag.value_as_string, + source_output, ) # import the sns Topic pipeline_notification_topic = sns.Topic.from_topic_arn( - self, "ImageBuilderPipelineNotification", mlops_sns_topic_arn.value_as_string + self, + "ImageBuilderPipelineNotification", + mlops_sns_topic_arn.value_as_string, ) # createing pipeline stages - source_stage = codepipeline.StageProps(stage_name="Source", actions=[source_action_definition]) - build_stage = codepipeline.StageProps(stage_name="Build", actions=[build_action_definition]) + source_stage = codepipeline.StageProps( + stage_name="Source", actions=[source_action_definition] + ) + build_stage = codepipeline.StageProps( + stage_name="Build", actions=[build_action_definition] + ) image_builder_pipeline = codepipeline.Pipeline( self, @@ -79,14 +97,16 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) ), ), - event_pattern=events.EventPattern(detail={"state": ["SUCCEEDED", "FAILED"]}), + event_pattern=events.EventPattern( + detail={"state": ["SUCCEEDED", "FAILED"]} + ), ) image_builder_pipeline.add_to_role_policy( iam.PolicyStatement( actions=["events:PutEvents"], resources=[ - f"arn:{core.Aws.PARTITION}:events:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:event-bus/*", + f"arn:{Aws.PARTITION}:events:{Aws.REGION}:{Aws.ACCOUNT_ID}:event-bus/*", ], ) ) @@ -105,15 +125,15 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: pipeline_permissions(image_builder_pipeline, assets_bucket) # Outputs # - core.CfnOutput( + CfnOutput( self, id="Pipelines", value=( f"https://console.aws.amazon.com/codesuite/codepipeline/pipelines/" - f"{image_builder_pipeline.pipeline_name}/view?region={core.Aws.REGION}" + f"{image_builder_pipeline.pipeline_name}/view?region={Aws.REGION}" ), ) - core.CfnOutput( + CfnOutput( self, id="CustomAlgorithmImageURI", value=container_uri, diff --git a/source/lib/blueprints/byom/model_monitor.py b/source/infrastructure/lib/blueprints/ml_pipelines/model_monitor.py similarity index 79% rename from source/lib/blueprints/byom/model_monitor.py rename to source/infrastructure/lib/blueprints/ml_pipelines/model_monitor.py index 0497ca6..57fc61a 100644 --- a/source/lib/blueprints/byom/model_monitor.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/model_monitor.py @@ -10,30 +10,39 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import ( - aws_s3 as s3, - core, -) -from lib.blueprints.byom.pipeline_definitions.deploy_actions import ( +from constructs import Construct +from aws_cdk import Stack, Aws, Fn, CfnOutput, aws_s3 as s3 +from lib.blueprints.pipeline_definitions.deploy_actions import ( create_baseline_job_lambda, sagemaker_layer, create_invoke_lambda_custom_resource, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ( +from lib.blueprints.pipeline_definitions.templates_parameters import ( ParameteresFactory as pf, ConditionsFactory as cf, ) -from lib.blueprints.byom.pipeline_definitions.sagemaker_monitor_role import create_sagemaker_monitor_role -from lib.blueprints.byom.pipeline_definitions.sagemaker_model_monitor_construct import SageMakerModelMonitor +from lib.blueprints.pipeline_definitions.sagemaker_monitor_role import ( + create_sagemaker_monitor_role, +) +from lib.blueprints.pipeline_definitions.sagemaker_model_monitor_construct import ( + SageMakerModelMonitor, +) -class ModelMonitorStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, monitoring_type: str, **kwargs) -> None: +class ModelMonitorStack(Stack): + def __init__( + self, scope: Construct, id: str, monitoring_type: str, **kwargs + ) -> None: super().__init__(scope, id, **kwargs) # validate the provided monitoring_type - if monitoring_type not in ["DataQuality", "ModelQuality", "ModelBias", "ModelExplainability"]: + if monitoring_type not in [ + "DataQuality", + "ModelQuality", + "ModelBias", + "ModelExplainability", + ]: raise ValueError( ( f"The {monitoring_type} is not valid. Supported Monitoring Types are: " @@ -50,25 +59,39 @@ def __init__(self, scope: core.Construct, id: str, monitoring_type: str, **kwarg self.blueprint_bucket_name = pf.create_blueprint_bucket_name_parameter(self) self.assets_bucket_name = pf.create_assets_bucket_name_parameter(self) self.endpoint_name = pf.create_endpoint_name_parameter(self) - self.baseline_job_output_location = pf.create_baseline_job_output_location_parameter(self) + self.baseline_job_output_location = ( + pf.create_baseline_job_output_location_parameter(self) + ) self.baseline_data = pf.create_baseline_data_parameter(self) self.instance_type = pf.create_instance_type_parameter(self) self.instance_count = pf.create_instance_count_parameter(self) self.instance_volume_size = pf.create_instance_volume_size_parameter(self) - self.baseline_max_runtime_seconds = pf.create_baseline_max_runtime_seconds_parameter(self) - self.monitor_max_runtime_seconds = pf.create_monitor_max_runtime_seconds_parameter(self, "ModelQuality") + self.baseline_max_runtime_seconds = ( + pf.create_baseline_max_runtime_seconds_parameter(self) + ) + self.monitor_max_runtime_seconds = ( + pf.create_monitor_max_runtime_seconds_parameter(self, "ModelQuality") + ) self.kms_key_arn = pf.create_kms_key_arn_parameter(self) self.baseline_job_name = pf.create_baseline_job_name_parameter(self) - self.monitoring_schedule_name = pf.create_monitoring_schedule_name_parameter(self) + self.monitoring_schedule_name = pf.create_monitoring_schedule_name_parameter( + self + ) self.data_capture_bucket = pf.create_data_capture_bucket_name_parameter(self) - self.baseline_output_bucket = pf.create_baseline_output_bucket_name_parameter(self) + self.baseline_output_bucket = pf.create_baseline_output_bucket_name_parameter( + self + ) self.data_capture_s3_location = pf.create_data_capture_location_parameter(self) - self.monitoring_output_location = pf.create_monitoring_output_location_parameter(self) + self.monitoring_output_location = ( + pf.create_monitoring_output_location_parameter(self) + ) self.schedule_expression = pf.create_schedule_expression_parameter(self) self.image_uri = pf.create_algorithm_image_uri_parameter(self) # common conditions - self.kms_key_arn_provided = cf.create_kms_key_arn_provided_condition(self, self.kms_key_arn) + self.kms_key_arn_provided = cf.create_kms_key_arn_provided_condition( + self, self.kms_key_arn + ) # Resources # self.assets_bucket = s3.Bucket.from_bucket_name( @@ -100,12 +123,16 @@ def __init__(self, scope: core.Construct, id: str, monitoring_type: str, **kwarg self._update_common_monitor_attributes() # create SageMaker monitoring Schedule - sagemaker_monitor = SageMakerModelMonitor(self, f"{monitoring_type}Monitor", **self.monitor_attributes) + sagemaker_monitor = SageMakerModelMonitor( + self, f"{monitoring_type}Monitor", **self.monitor_attributes + ) # add job definition dependency on sagemaker role and invoke_lambda_custom_resource # (so, the baseline job is created) sagemaker_monitor.job_definition.node.add_dependency(self.sagemaker_role) - sagemaker_monitor.job_definition.node.add_dependency(invoke_lambda_custom_resource) + sagemaker_monitor.job_definition.node.add_dependency( + invoke_lambda_custom_resource + ) # Outputs # self._create_stack_outputs() @@ -122,11 +149,13 @@ def _update_common_baseline_attributes(self): instance_type=self.instance_type.value_as_string, instance_volume_size=self.instance_volume_size.value_as_string, max_runtime_seconds=self.baseline_max_runtime_seconds.value_as_string, - kms_key_arn=core.Fn.condition_if( - self.kms_key_arn_provided.logical_id, self.kms_key_arn.value_as_string, core.Aws.NO_VALUE + kms_key_arn=Fn.condition_if( + self.kms_key_arn_provided.logical_id, + self.kms_key_arn.value_as_string, + Aws.NO_VALUE, ).to_string(), kms_key_arn_provided_condition=self.kms_key_arn_provided, - stack_name=core.Aws.STACK_NAME, + stack_name=Aws.STACK_NAME, ) ) @@ -142,13 +171,15 @@ def _update_common_monitor_attributes(self): instance_count=self.instance_count.value_as_string, instance_volume_size=self.instance_volume_size.value_as_string, max_runtime_seconds=self.monitor_max_runtime_seconds.value_as_string, - kms_key_arn=core.Fn.condition_if( - self.kms_key_arn_provided.logical_id, self.kms_key_arn.value_as_string, core.Aws.NO_VALUE + kms_key_arn=Fn.condition_if( + self.kms_key_arn_provided.logical_id, + self.kms_key_arn.value_as_string, + Aws.NO_VALUE, ).to_string(), role_arn=self.sagemaker_role.role_arn, image_uri=self.image_uri.value_as_string, monitoring_type=self.monitoring_type, - tags=[{"key": "stack-name", "value": core.Aws.STACK_NAME}], + tags=[{"key": "stack-name", "value": Aws.STACK_NAME}], ) ) @@ -221,9 +252,15 @@ def _add_model_quality_resources(self): """ # add baseline job attributes (they are different from Monitor attributes) if self.monitoring_type == "ModelQuality": - self.baseline_inference_attribute = pf.create_inference_attribute_parameter(self, "Baseline") - self.baseline_probability_attribute = pf.create_probability_attribute_parameter(self, "Baseline") - self.ground_truth_attribute = pf.create_ground_truth_attribute_parameter(self) + self.baseline_inference_attribute = pf.create_inference_attribute_parameter( + self, "Baseline" + ) + self.baseline_probability_attribute = ( + pf.create_probability_attribute_parameter(self, "Baseline") + ) + self.ground_truth_attribute = pf.create_ground_truth_attribute_parameter( + self + ) # add ModelQuality Baseline attributes self.baseline_attributes.update( dict( @@ -233,17 +270,29 @@ def _add_model_quality_resources(self): ) ) # add monitor attributes - self.monitor_inference_attribute = pf.create_inference_attribute_parameter(self, "Monitor") - self.monitor_probability_attribute = pf.create_probability_attribute_parameter(self, "Monitor") + self.monitor_inference_attribute = pf.create_inference_attribute_parameter( + self, "Monitor" + ) + self.monitor_probability_attribute = pf.create_probability_attribute_parameter( + self, "Monitor" + ) # only create ground_truth_s3_url parameter for ModelQuality/Bias if self.monitoring_type in ["ModelQuality", "ModelBias"]: # ground_truth_s3_uri is only for ModelQuality/ModelBias - self.ground_truth_s3_bucket = pf.create_ground_truth_bucket_name_parameter(self) + self.ground_truth_s3_bucket = pf.create_ground_truth_bucket_name_parameter( + self + ) self.ground_truth_s3_uri = pf.create_ground_truth_s3_uri_parameter(self) - self.monitor_attributes.update(dict(ground_truth_s3_uri=f"s3://{self.ground_truth_s3_uri.value_as_string}")) + self.monitor_attributes.update( + dict( + ground_truth_s3_uri=f"s3://{self.ground_truth_s3_uri.value_as_string}" + ) + ) # problem_type and probability_threshold_attribute are the same for both self.problem_type = pf.create_problem_type_parameter(self) - self.probability_threshold_attribute = pf.create_probability_threshold_attribute_parameter(self) + self.probability_threshold_attribute = ( + pf.create_probability_threshold_attribute_parameter(self) + ) # add conditions (used by monitor) self.inference_attribute_provided = cf.create_attribute_provided_condition( @@ -252,12 +301,18 @@ def _add_model_quality_resources(self): self.binary_classification_propability_attribute_provided = ( cf.create_problem_type_binary_classification_attribute_provided_condition( - self, self.problem_type, self.monitor_probability_attribute, "ProbabilityAttribute" + self, + self.problem_type, + self.monitor_probability_attribute, + "ProbabilityAttribute", ) ) self.binary_classification_propability_threshold_provided = ( cf.create_problem_type_binary_classification_attribute_provided_condition( - self, self.problem_type, self.probability_threshold_attribute, "ProbabilityThreshold" + self, + self.problem_type, + self.probability_threshold_attribute, + "ProbabilityThreshold", ) ) @@ -274,22 +329,22 @@ def _add_model_quality_resources(self): dict( problem_type=self.problem_type.value_as_string, # pass inference_attribute if provided - inference_attribute=core.Fn.condition_if( + inference_attribute=Fn.condition_if( self.inference_attribute_provided.logical_id, self.monitor_inference_attribute.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), # pass probability_attribute if provided and ProblemType is BinaryClassification - probability_attribute=core.Fn.condition_if( + probability_attribute=Fn.condition_if( self.binary_classification_propability_attribute_provided.logical_id, self.monitor_probability_attribute.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), # pass probability_threshold_attribute if provided and ProblemType is BinaryClassification - probability_threshold_attribute=core.Fn.condition_if( + probability_threshold_attribute=Fn.condition_if( self.binary_classification_propability_threshold_provided.logical_id, self.probability_threshold_attribute.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), ) ) @@ -299,17 +354,23 @@ def _add_model_bias_explainability_extra_attributes(self): # create bias specific paramaters if self.monitoring_type == "ModelBias": self.base_config = pf.create_bias_config_parameter(self) - self.model_predicted_label_config = pf.create_model_predicted_label_config_parameter(self) - self.model_predicted_label_config_provided = cf.create_attribute_provided_condition( - self, "PredictedLabelConfigProvided", self.model_predicted_label_config + self.model_predicted_label_config = ( + pf.create_model_predicted_label_config_parameter(self) + ) + self.model_predicted_label_config_provided = ( + cf.create_attribute_provided_condition( + self, + "PredictedLabelConfigProvided", + self.model_predicted_label_config, + ) ) # update baseline attributes self.baseline_attributes.update( dict( - model_predicted_label_config=core.Fn.condition_if( + model_predicted_label_config=Fn.condition_if( self.model_predicted_label_config_provided.logical_id, self.model_predicted_label_config.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), bias_config=self.base_config.value_as_string, ) @@ -325,10 +386,10 @@ def _add_model_bias_explainability_extra_attributes(self): self.baseline_attributes.update( dict( shap_config=self.shap_config.value_as_string, - model_scores=core.Fn.condition_if( + model_scores=Fn.condition_if( self.model_scores_provided.logical_id, self.model_scores.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), ) ) @@ -341,36 +402,36 @@ def _add_model_bias_explainability_extra_attributes(self): # update monitor attributes self.monitor_attributes.update( dict( - features_attribute=core.Fn.condition_if( + features_attribute=Fn.condition_if( self.features_attribute_provided.logical_id, self.features_attribute.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), ) ) def _create_stack_outputs(self): - core.CfnOutput( + CfnOutput( self, id="BaselineName", value=self.baseline_job_name.value_as_string, ) - core.CfnOutput( + CfnOutput( self, id="MonitoringScheduleJobName", value=self.monitoring_schedule_name.value_as_string, ) - core.CfnOutput( + CfnOutput( self, id="MonitoringScheduleType", value=self.monitoring_type, ) - core.CfnOutput( + CfnOutput( self, id="BaselineJobOutput", value=f"https://s3.console.aws.amazon.com/s3/buckets/{self.baseline_job_output_location.value_as_string}/", ) - core.CfnOutput( + CfnOutput( self, id="MonitoringScheduleOutput", value=( @@ -378,12 +439,12 @@ def _create_stack_outputs(self): f"{self.endpoint_name.value_as_string}/{self.monitoring_schedule_name.value_as_string}/" ), ) - core.CfnOutput( + CfnOutput( self, id="MonitoredSagemakerEndpoint", value=self.endpoint_name.value_as_string, ) - core.CfnOutput( + CfnOutput( self, id="DataCaptureS3Location", value=( diff --git a/source/lib/blueprints/byom/model_training_pipeline.py b/source/infrastructure/lib/blueprints/ml_pipelines/model_training_pipeline.py similarity index 86% rename from source/lib/blueprints/byom/model_training_pipeline.py rename to source/infrastructure/lib/blueprints/ml_pipelines/model_training_pipeline.py index 049d0ce..4237550 100644 --- a/source/lib/blueprints/byom/model_training_pipeline.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/model_training_pipeline.py @@ -10,27 +10,31 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### +from constructs import Construct from aws_cdk import ( + Stack, + Aws, + Fn, + CfnOutput, aws_s3 as s3, aws_events as events, aws_sns as sns, - core, ) -from lib.blueprints.byom.pipeline_definitions.deploy_actions import ( +from lib.blueprints.pipeline_definitions.deploy_actions import ( model_training_job, sagemaker_layer, create_invoke_lambda_custom_resource, eventbridge_rule_to_sns, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ( +from lib.blueprints.pipeline_definitions.templates_parameters import ( ParameteresFactory as pf, ConditionsFactory as cf, ) -class TrainingJobStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, training_type: str, **kwargs) -> None: +class TrainingJobStack(Stack): + def __init__(self, scope: Construct, id: str, training_type: str, **kwargs) -> None: super().__init__(scope, id, **kwargs) self.training_type = training_type @@ -46,8 +50,12 @@ def __init__(self, scope: core.Construct, id: str, training_type: str, **kwargs) self.kms_key_arn = pf.create_kms_key_arn_parameter(self) self.training_data = pf.create_training_data_parameter(self) self.validation_data = pf.create_validation_data_parameter(self) - self.encrypt_inter_container_traffic = pf.create_encrypt_inner_traffic_parameter(self) - self.max_runtime_per_training_job_in_seconds = pf.create_max_runtime_per_job_parameter(self) + self.encrypt_inter_container_traffic = ( + pf.create_encrypt_inner_traffic_parameter(self) + ) + self.max_runtime_per_training_job_in_seconds = ( + pf.create_max_runtime_per_job_parameter(self) + ) self.use_spot_instances = pf.create_use_spot_instances_parameter(self) self.max_wait_time_for_spot = pf.create_max_wait_time_for_spot_parameter(self) self.content_type = pf.create_content_type_parameter(self) @@ -84,7 +92,9 @@ def __init__(self, scope: core.Construct, id: str, training_type: str, **kwargs) self.compression_type_provided = cf.create_attribute_provided_condition( self, "CompressionTypeProvided", self.compression_type ) - self.kms_key_arn_provided = cf.create_attribute_provided_condition(self, "KMSProvided", self.kms_key_arn) + self.kms_key_arn_provided = cf.create_attribute_provided_condition( + self, "KMSProvided", self.kms_key_arn + ) self.attribute_names_provided = cf.create_attribute_provided_condition( self, "AttributeNamesProvided", self.attribute_names @@ -97,25 +107,29 @@ def __init__(self, scope: core.Construct, id: str, training_type: str, **kwargs) job_type=training_type, job_name=self.job_name.value_as_string, training_data=self.training_data.value_as_string, - validation_data=core.Fn.condition_if( - self.validation_data_provided.logical_id, self.validation_data.value_as_string, core.Aws.NO_VALUE + validation_data=Fn.condition_if( + self.validation_data_provided.logical_id, + self.validation_data.value_as_string, + Aws.NO_VALUE, ).to_string(), s3_data_type=self.s3_data_type.value_as_string, content_type=self.content_type.value_as_string, data_distribution=self.data_distribution.value_as_string, - compression_type=core.Fn.condition_if( - self.compression_type_provided.logical_id, self.compression_type.value_as_string, core.Aws.NO_VALUE + compression_type=Fn.condition_if( + self.compression_type_provided.logical_id, + self.compression_type.value_as_string, + Aws.NO_VALUE, ).to_string(), data_input_mode=self.data_input_mode.value_as_string, - data_record_wrapping=core.Fn.condition_if( + data_record_wrapping=Fn.condition_if( self.data_record_wrapping_provided.logical_id, self.data_record_wrapping.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), - attribute_names=core.Fn.condition_if( + attribute_names=Fn.condition_if( self.attribute_names_provided.logical_id, self.attribute_names.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), hyperparameters=self.hyperparameters.value_as_string, job_output_location=self.job_output_location.value_as_string, @@ -123,8 +137,10 @@ def __init__(self, scope: core.Construct, id: str, training_type: str, **kwargs) instance_type=self.instance_type.value_as_string, instance_count=self.instance_count.value_as_string, instance_volume_size=self.instance_volume_size.value_as_string, - kms_key_arn=core.Fn.condition_if( - self.kms_key_arn_provided.logical_id, self.kms_key_arn.value_as_string, core.Aws.NO_VALUE + kms_key_arn=Fn.condition_if( + self.kms_key_arn_provided.logical_id, + self.kms_key_arn.value_as_string, + Aws.NO_VALUE, ).to_string(), encrypt_inter_container_traffic=self.encrypt_inter_container_traffic.value_as_string, max_runtime_per_training_job_in_seconds=self.max_runtime_per_training_job_in_seconds.value_as_string, @@ -135,7 +151,9 @@ def __init__(self, scope: core.Construct, id: str, training_type: str, **kwargs) # add HyperparameterTuningJob specific parameters if training_type == "HyperparameterTuningJob": self.tuner_config = pf.create_tuner_config_parameter(self) - self.hyperparameters_ranges = pf.create_hyperparameters_range_parameter(self) + self.hyperparameters_ranges = pf.create_hyperparameters_range_parameter( + self + ) # update the training attributes self.training_attributes.update( { @@ -149,7 +167,8 @@ def __init__(self, scope: core.Construct, id: str, training_type: str, **kwargs) # create custom resource to invoke the training job lambda invoke_lambda_custom_resource = self._create_invoke_lambda_custom_resource( - function_name=training_lambda.function_name, function_arn=training_lambda.function_arn + function_name=training_lambda.function_name, + function_arn=training_lambda.function_arn, ) # create dependency on the training lambda @@ -167,11 +186,16 @@ def _create_model_training_lambda(self): # update the training attributes self.training_attributes.update( - {"sm_layer": sm_layer, "kms_key_arn_provided_condition": self.kms_key_arn_provided} + { + "sm_layer": sm_layer, + "kms_key_arn_provided_condition": self.kms_key_arn_provided, + } ) # create training job lambda - training_lambda = model_training_job(scope=self, id="ModelTrainingLambda", **self.training_attributes) + training_lambda = model_training_job( + scope=self, id="ModelTrainingLambda", **self.training_attributes + ) return training_lambda @@ -245,26 +269,26 @@ def _create_job_notification_rule( ) def _create_stack_outputs(self): - core.CfnOutput( + CfnOutput( self, id="TrainingJobName", value=self.job_name.value_as_string, description="The training job's name", ) - core.CfnOutput( + CfnOutput( self, id="TrainingJobOutputLocation", value=f"https://s3.console.aws.amazon.com/s3/buckets/{self.assets_bucket_name.value_as_string}/{self.job_output_location.value_as_string}/", description="Output location of the training job", ) - core.CfnOutput( + CfnOutput( self, id="TrainingDataLocation", value=f"https://s3.console.aws.amazon.com/s3/buckets/{self.assets_bucket_name.value_as_string}/{self.training_data.value_as_string}", description="Training data used by the training job", ) - core.CfnOutput( + CfnOutput( self, id="ValidationDataLocation", value=f"https://s3.console.aws.amazon.com/s3/buckets/{self.assets_bucket_name.value_as_string}/{self.training_data.value_as_string}", diff --git a/source/lib/blueprints/byom/multi_account_codepipeline.py b/source/infrastructure/lib/blueprints/ml_pipelines/multi_account_codepipeline.py similarity index 78% rename from source/lib/blueprints/byom/multi_account_codepipeline.py rename to source/infrastructure/lib/blueprints/ml_pipelines/multi_account_codepipeline.py index 80f273b..0fea375 100644 --- a/source/lib/blueprints/byom/multi_account_codepipeline.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/multi_account_codepipeline.py @@ -10,54 +10,74 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### +from constructs import Construct from aws_cdk import ( + Stack, + Aws, + Fn, + CfnOutput, aws_iam as iam, aws_s3 as s3, aws_sns as sns, aws_events_targets as targets, aws_events as events, aws_codepipeline as codepipeline, - core, ) -from lib.blueprints.byom.pipeline_definitions.source_actions import source_action_template -from lib.blueprints.byom.pipeline_definitions.deploy_actions import create_stackset_action +from lib.blueprints.pipeline_definitions.source_actions import ( + source_action_template, +) +from lib.blueprints.pipeline_definitions.deploy_actions import ( + create_stackset_action, +) -from lib.blueprints.byom.pipeline_definitions.approval_actions import approval_action -from lib.blueprints.byom.pipeline_definitions.helpers import ( +from lib.blueprints.pipeline_definitions.approval_actions import approval_action +from lib.blueprints.pipeline_definitions.helpers import ( pipeline_permissions, suppress_list_function_policy, suppress_pipeline_bucket, suppress_iam_complex, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ( +from lib.blueprints.pipeline_definitions.templates_parameters import ( ParameteresFactory as pf, ConditionsFactory as cf, ) -class MultiAccountCodePipelineStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: +class MultiAccountCodePipelineStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: super().__init__(scope, id, **kwargs) # Parameteres # template_zip_name = pf.create_template_zip_name_parameter(self) template_file_name = pf.create_template_file_name_parameter(self) - dev_params_file_name = pf.create_stage_params_file_name_parameter(self, "DevParamsName", "development") - staging_params_file_name = pf.create_stage_params_file_name_parameter(self, "StagingParamsName", "staging") - prod_params_file_name = pf.create_stage_params_file_name_parameter(self, "ProdParamsName", "production") + dev_params_file_name = pf.create_stage_params_file_name_parameter( + self, "DevParamsName", "development" + ) + staging_params_file_name = pf.create_stage_params_file_name_parameter( + self, "StagingParamsName", "staging" + ) + prod_params_file_name = pf.create_stage_params_file_name_parameter( + self, "ProdParamsName", "production" + ) mlops_sns_topic_arn = pf.create_sns_topic_arn_parameter(self) # create development parameters account_type = "development" - dev_account_id = pf.create_account_id_parameter(self, "DevAccountId", account_type) + dev_account_id = pf.create_account_id_parameter( + self, "DevAccountId", account_type + ) dev_org_id = pf.create_org_id_parameter(self, "DevOrgId", account_type) # create staging parameters account_type = "staging" - staging_account_id = pf.create_account_id_parameter(self, "StagingAccountId", account_type) + staging_account_id = pf.create_account_id_parameter( + self, "StagingAccountId", account_type + ) staging_org_id = pf.create_org_id_parameter(self, "StagingOrgId", account_type) # create production parameters account_type = "production" - prod_account_id = pf.create_account_id_parameter(self, "ProdAccountId", account_type) + prod_account_id = pf.create_account_id_parameter( + self, "ProdAccountId", account_type + ) prod_org_id = pf.create_org_id_parameter(self, "ProdOrgId", account_type) # assets parameters blueprint_bucket_name = pf.create_blueprint_bucket_name_parameter(self) @@ -66,10 +86,14 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: # delegated admin account is_delegated_admin = pf.create_delegated_admin_parameter(self) # create use delegated admin account condition - delegated_admin_account_condition = cf.create_delegated_admin_condition(self, is_delegated_admin) + delegated_admin_account_condition = cf.create_delegated_admin_condition( + self, is_delegated_admin + ) # Resources # - assets_bucket = s3.Bucket.from_bucket_name(self, "ImportedAssetsBucket", assets_bucket_name.value_as_string) + assets_bucket = s3.Bucket.from_bucket_name( + self, "ImportedAssetsBucket", assets_bucket_name.value_as_string + ) # getting blueprint bucket object from its name - will be used later in the stack blueprint_bucket = s3.Bucket.from_bucket_name( @@ -83,13 +107,18 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: # Defining pipeline stages # source stage - source_output, source_action_definition = source_action_template(template_zip_name, assets_bucket) + source_output, source_action_definition = source_action_template( + template_zip_name, assets_bucket + ) # use the first 8 characters from last portion of the stack_id as a unique id to be appended # to stacksets names. Example stack_id: # arn:aws:cloudformation:::stack//e45f0f20-c886-11eb-98d4-0a1157964cc9 # the selected id would be e45f0f20 - unique_id = core.Fn.select(0, core.Fn.split("-", core.Fn.select(2, core.Fn.split("/", core.Aws.STACK_ID)))) + unique_id = Fn.select( + 0, + Fn.split("-", Fn.select(2, Fn.split("/", Aws.STACK_ID))), + ) # DeployDev stage dev_deploy_lambda_arn, dev_stackset_action = create_stackset_action( @@ -102,7 +131,7 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: dev_params_file_name.value_as_string, [dev_account_id.value_as_string], [dev_org_id.value_as_string], - [core.Aws.REGION], + [Aws.REGION], f"{stack_name.value_as_string}-dev-{unique_id}", delegated_admin_account_condition, ) @@ -125,7 +154,7 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: staging_params_file_name.value_as_string, [staging_account_id.value_as_string], [staging_org_id.value_as_string], - [core.Aws.REGION], + [Aws.REGION], f"{stack_name.value_as_string}-staging-{unique_id}", delegated_admin_account_condition, ) @@ -148,7 +177,7 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: prod_params_file_name.value_as_string, [prod_account_id.value_as_string], [prod_org_id.value_as_string], - [core.Aws.REGION], + [Aws.REGION], f"{stack_name.value_as_string}-prod-{unique_id}", delegated_admin_account_condition, ) @@ -158,11 +187,17 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: actions=[ "lambda:InvokeFunction", ], - resources=[dev_deploy_lambda_arn, staging_deploy_lambda_arn, prod_deploy_lambda_arn], + resources=[ + dev_deploy_lambda_arn, + staging_deploy_lambda_arn, + prod_deploy_lambda_arn, + ], ) # createing pipeline stages - source_stage = codepipeline.StageProps(stage_name="Source", actions=[source_action_definition]) + source_stage = codepipeline.StageProps( + stage_name="Source", actions=[source_action_definition] + ) deploy_dev_stage = codepipeline.StageProps( stage_name="DeployDev", @@ -183,7 +218,12 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: multi_account_pipeline = codepipeline.Pipeline( self, "MultiAccountPipeline", - stages=[source_stage, deploy_dev_stage, deploy_staging_stage, deploy_prod_stage], + stages=[ + source_stage, + deploy_dev_stage, + deploy_staging_stage, + deploy_prod_stage, + ], cross_account_keys=False, ) @@ -201,7 +241,9 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) ), ), - event_pattern=events.EventPattern(detail={"state": ["SUCCEEDED", "FAILED"]}), + event_pattern=events.EventPattern( + detail={"state": ["SUCCEEDED", "FAILED"]} + ), ) # add notification to the staging stackset action @@ -218,7 +260,9 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) ), ), - event_pattern=events.EventPattern(detail={"state": ["SUCCEEDED", "FAILED"]}), + event_pattern=events.EventPattern( + detail={"state": ["SUCCEEDED", "FAILED"]} + ), ) # add notification to the production stackset action @@ -235,7 +279,9 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) ), ), - event_pattern=events.EventPattern(detail={"state": ["SUCCEEDED", "FAILED"]}), + event_pattern=events.EventPattern( + detail={"state": ["SUCCEEDED", "FAILED"]} + ), ) # add notification to the multi-account pipeline @@ -251,13 +297,15 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) ), ), - event_pattern=events.EventPattern(detail={"state": ["SUCCEEDED", "FAILED"]}), + event_pattern=events.EventPattern( + detail={"state": ["SUCCEEDED", "FAILED"]} + ), ) multi_account_pipeline.add_to_role_policy( iam.PolicyStatement( actions=["events:PutEvents"], resources=[ - f"arn:{core.Aws.PARTITION}:events:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:event-bus/*", + f"arn:{Aws.PARTITION}:events:{Aws.REGION}:{Aws.ACCOUNT_ID}:event-bus/*", ], ) ) @@ -266,25 +314,29 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: multi_account_pipeline.add_to_role_policy(invoke_lambdas_policy) # add cfn suppressions for Lambda:ListFunctions * resource - multi_account_pipeline.node.find_child("DeployDev").node.find_child("DeployDevStackSet").node.find_child( - "CodePipelineActionRole" - ).node.find_child("DefaultPolicy").node.default_child.cfn_options.metadata = suppress_list_function_policy() + multi_account_pipeline.node.find_child("DeployDev").node.find_child( + "DeployDevStackSet" + ).node.find_child("CodePipelineActionRole").node.find_child( + "DefaultPolicy" + ).node.default_child.cfn_options.metadata = suppress_list_function_policy() multi_account_pipeline.node.find_child("DeployStaging").node.find_child( "DeployStagingStackSet" ).node.find_child("CodePipelineActionRole").node.find_child( "DefaultPolicy" ).node.default_child.cfn_options.metadata = suppress_list_function_policy() - multi_account_pipeline.node.find_child("DeployProd").node.find_child("DeployProdStackSet").node.find_child( - "CodePipelineActionRole" - ).node.find_child("DefaultPolicy").node.default_child.cfn_options.metadata = suppress_list_function_policy() + multi_account_pipeline.node.find_child("DeployProd").node.find_child( + "DeployProdStackSet" + ).node.find_child("CodePipelineActionRole").node.find_child( + "DefaultPolicy" + ).node.default_child.cfn_options.metadata = suppress_list_function_policy() - # add supression for complex policy + # add suppression for complex policy multi_account_pipeline.node.find_child("Role").node.find_child( "DefaultPolicy" ).node.default_child.cfn_options.metadata = suppress_iam_complex() - # add ArtifactBucket cfn supression (not needing a logging bucket) + # add ArtifactBucket cfn suppression (not needing a logging bucket) multi_account_pipeline.node.find_child( "ArtifactsBucket" ).node.default_child.cfn_options.metadata = suppress_pipeline_bucket() @@ -292,11 +344,11 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: pipeline_permissions(multi_account_pipeline, assets_bucket) # Outputs # - core.CfnOutput( + CfnOutput( self, id="Pipelines", value=( f"https://console.aws.amazon.com/codesuite/codepipeline/pipelines/" - f"{multi_account_pipeline.pipeline_name}/view?region={core.Aws.REGION}" + f"{multi_account_pipeline.pipeline_name}/view?region={Aws.REGION}" ), ) diff --git a/source/lib/blueprints/byom/realtime_inference_pipeline.py b/source/infrastructure/lib/blueprints/ml_pipelines/realtime_inference_pipeline.py similarity index 77% rename from source/lib/blueprints/byom/realtime_inference_pipeline.py rename to source/infrastructure/lib/blueprints/ml_pipelines/realtime_inference_pipeline.py index 6424c9b..eb6d002 100644 --- a/source/lib/blueprints/byom/realtime_inference_pipeline.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/realtime_inference_pipeline.py @@ -10,34 +10,51 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### +from constructs import Construct from aws_cdk import ( + Stack, + Aws, + Fn, + Duration, + CfnOutput, aws_lambda as lambda_, aws_s3 as s3, aws_apigateway as apigw, - core, ) -from aws_solutions_constructs.aws_lambda_sagemakerendpoint import LambdaToSagemakerEndpoint +from aws_solutions_constructs.aws_lambda_sagemakerendpoint import ( + LambdaToSagemakerEndpoint, +) from aws_solutions_constructs import aws_apigateway_lambda -from lib.blueprints.byom.pipeline_definitions.sagemaker_role import create_sagemaker_role -from lib.blueprints.byom.pipeline_definitions.sagemaker_model import create_sagemaker_model -from lib.blueprints.byom.pipeline_definitions.sagemaker_endpoint_config import create_sagemaker_endpoint_config -from lib.blueprints.byom.pipeline_definitions.sagemaker_endpoint import create_sagemaker_endpoint -from lib.blueprints.byom.pipeline_definitions.helpers import suppress_lambda_policies -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ( +from lib.blueprints.pipeline_definitions.sagemaker_role import ( + create_sagemaker_role, +) +from lib.blueprints.pipeline_definitions.sagemaker_model import ( + create_sagemaker_model, +) +from lib.blueprints.pipeline_definitions.sagemaker_endpoint_config import ( + create_sagemaker_endpoint_config, +) +from lib.blueprints.pipeline_definitions.sagemaker_endpoint import ( + create_sagemaker_endpoint, +) +from lib.blueprints.pipeline_definitions.helpers import suppress_lambda_policies +from lib.blueprints.pipeline_definitions.templates_parameters import ( ParameteresFactory as pf, ConditionsFactory as cf, ) -class BYOMRealtimePipelineStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: +class BYOMRealtimePipelineStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: super().__init__(scope, id, **kwargs) # Parameteres # assets_bucket_name = pf.create_assets_bucket_name_parameter(self) blueprint_bucket_name = pf.create_blueprint_bucket_name_parameter(self) - custom_algorithms_ecr_repo_arn = pf.create_custom_algorithms_ecr_repo_arn_parameter(self) + custom_algorithms_ecr_repo_arn = ( + pf.create_custom_algorithms_ecr_repo_arn_parameter(self) + ) kms_key_arn = pf.create_kms_key_arn_parameter(self) algorithm_image_uri = pf.create_algorithm_image_uri_parameter(self) model_name = pf.create_model_name_parameter(self) @@ -50,12 +67,20 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: endpoint_name = pf.create_endpoint_name_parameter(self, optional=True) # Conditions - custom_algorithms_ecr_repo_arn_provided = cf.create_custom_algorithms_ecr_repo_arn_provided_condition( - self, custom_algorithms_ecr_repo_arn + custom_algorithms_ecr_repo_arn_provided = ( + cf.create_custom_algorithms_ecr_repo_arn_provided_condition( + self, custom_algorithms_ecr_repo_arn + ) + ) + kms_key_arn_provided = cf.create_kms_key_arn_provided_condition( + self, kms_key_arn + ) + model_registry_provided = cf.create_model_registry_provided_condition( + self, model_package_name + ) + endpoint_name_provided = cf.create_endpoint_name_provided_condition( + self, endpoint_name ) - kms_key_arn_provided = cf.create_kms_key_arn_provided_condition(self, kms_key_arn) - model_registry_provided = cf.create_model_registry_provided_condition(self, model_package_name) - endpoint_name_provided = cf.create_endpoint_name_provided_condition(self, endpoint_name) # Resources # # getting blueprint bucket object from its name - will be used later in the stack @@ -68,22 +93,28 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: self, "BYOMInference", lambda_function_props={ - "runtime": lambda_.Runtime.PYTHON_3_9, + "runtime": lambda_.Runtime.PYTHON_3_10, "handler": "main.handler", - "code": lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/inference.zip"), - "timeout": core.Duration.minutes(5) + "code": lambda_.Code.from_bucket( + blueprint_bucket, "blueprints/lambdas/inference.zip" + ), + "timeout": Duration.minutes(5), }, api_gateway_props={ "defaultMethodOptions": { "authorizationType": apigw.AuthorizationType.IAM, }, - "restApiName": f"{core.Aws.STACK_NAME}-inference", + "restApiName": f"{Aws.STACK_NAME}-inference", "proxy": False, }, ) # add suppressions - inference_api_gateway.lambda_function.node.default_child.cfn_options.metadata = suppress_lambda_policies() - provision_resource = inference_api_gateway.api_gateway.root.add_resource("inference") + inference_api_gateway.lambda_function.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) + provision_resource = inference_api_gateway.api_gateway.root.add_resource( + "inference" + ) provision_resource.add_method("POST") # create Sagemaker role @@ -126,29 +157,31 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: model_name=model_name.value_as_string, inference_instance=inference_instance.value_as_string, data_capture_location=data_capture_location.value_as_string, - kms_key_arn=core.Fn.condition_if( - kms_key_arn_provided.logical_id, kms_key_arn.value_as_string, core.Aws.NO_VALUE + kms_key_arn=Fn.condition_if( + kms_key_arn_provided.logical_id, + kms_key_arn.value_as_string, + Aws.NO_VALUE, ).to_string(), ) # create a dependency on the model - sagemaker_endpoint_config.add_depends_on(sagemaker_model) + sagemaker_endpoint_config.add_dependency(sagemaker_model) # create Sagemaker endpoint sagemaker_endpoint = create_sagemaker_endpoint( scope=self, id="MLOpsSagemakerEndpoint", endpoint_config_name=sagemaker_endpoint_config.attr_endpoint_config_name, - endpoint_name=core.Fn.condition_if( + endpoint_name=Fn.condition_if( endpoint_name_provided.logical_id, endpoint_name.value_as_string, - core.Aws.NO_VALUE, + Aws.NO_VALUE, ).to_string(), model_name=model_name.value_as_string, ) # add dependency on endpoint config - sagemaker_endpoint.add_depends_on(sagemaker_endpoint_config) + sagemaker_endpoint.add_dependency(sagemaker_endpoint_config) # Create Lambda - sagemakerendpoint LambdaToSagemakerEndpoint( @@ -159,22 +192,22 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) # Outputs # - core.CfnOutput( + CfnOutput( self, id="SageMakerModelName", value=sagemaker_model.attr_model_name, ) - core.CfnOutput( + CfnOutput( self, id="SageMakerEndpointConfigName", value=sagemaker_endpoint_config.attr_endpoint_config_name, ) - core.CfnOutput( + CfnOutput( self, id="SageMakerEndpointName", value=sagemaker_endpoint.attr_endpoint_name, ) - core.CfnOutput( + CfnOutput( self, id="EndpointDataCaptureLocation", value=f"https://s3.console.aws.amazon.com/s3/buckets/{data_capture_location.value_as_string}/", diff --git a/source/lib/blueprints/byom/single_account_codepipeline.py b/source/infrastructure/lib/blueprints/ml_pipelines/single_account_codepipeline.py similarity index 78% rename from source/lib/blueprints/byom/single_account_codepipeline.py rename to source/infrastructure/lib/blueprints/ml_pipelines/single_account_codepipeline.py index 20f24b1..b8dff81 100644 --- a/source/lib/blueprints/byom/single_account_codepipeline.py +++ b/source/infrastructure/lib/blueprints/ml_pipelines/single_account_codepipeline.py @@ -10,40 +10,53 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### +from constructs import Construct from aws_cdk import ( + Stack, + Aws, + CfnOutput, aws_iam as iam, aws_s3 as s3, aws_sns as sns, aws_events_targets as targets, aws_events as events, aws_codepipeline as codepipeline, - core, ) -from lib.blueprints.byom.pipeline_definitions.source_actions import source_action_template -from lib.blueprints.byom.pipeline_definitions.deploy_actions import create_cloudformation_action -from lib.blueprints.byom.pipeline_definitions.helpers import ( +from lib.blueprints.pipeline_definitions.source_actions import ( + source_action_template, +) +from lib.blueprints.pipeline_definitions.deploy_actions import ( + create_cloudformation_action, +) +from lib.blueprints.pipeline_definitions.helpers import ( pipeline_permissions, suppress_pipeline_bucket, suppress_iam_complex, suppress_cloudformation_action, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ParameteresFactory as pf +from lib.blueprints.pipeline_definitions.templates_parameters import ( + ParameteresFactory as pf, +) -class SingleAccountCodePipelineStack(core.Stack): - def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: +class SingleAccountCodePipelineStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: super().__init__(scope, id, **kwargs) # Parameteres # template_zip_name = pf.create_template_zip_name_parameter(self) template_file_name = pf.create_template_file_name_parameter(self) - template_params_file_name = pf.create_stage_params_file_name_parameter(self, "TemplateParamsName", "main") + template_params_file_name = pf.create_stage_params_file_name_parameter( + self, "TemplateParamsName", "main" + ) assets_bucket_name = pf.create_assets_bucket_name_parameter(self) stack_name = pf.create_stack_name_parameter(self) sns_topic_arn = pf.create_sns_topic_arn_parameter(self) # Resources # - assets_bucket = s3.Bucket.from_bucket_name(self, "ImportedAssetsBucket", assets_bucket_name.value_as_string) + assets_bucket = s3.Bucket.from_bucket_name( + self, "ImportedAssetsBucket", assets_bucket_name.value_as_string + ) # import the sns Topic pipeline_notification_topic = sns.Topic.from_topic_arn( @@ -52,7 +65,9 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: # Defining pipeline stages # source stage - source_output, source_action_definition = source_action_template(template_zip_name, assets_bucket) + source_output, source_action_definition = source_action_template( + template_zip_name, assets_bucket + ) # create cloudformation action cloudformation_action = create_cloudformation_action( @@ -63,7 +78,9 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: template_params_file_name.value_as_string, ) - source_stage = codepipeline.StageProps(stage_name="Source", actions=[source_action_definition]) + source_stage = codepipeline.StageProps( + stage_name="Source", actions=[source_action_definition] + ) deploy = codepipeline.StageProps( stage_name="DeployCloudFormation", actions=[cloudformation_action], @@ -77,8 +94,9 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) # Add CF suppressions to the action - deployment_policy = cloudformation_action.deployment_role.node.find_all()[2] - deployment_policy.node.default_child.cfn_options.metadata = suppress_cloudformation_action() + cloudformation_action.deployment_role.node.find_child( + "DefaultPolicy" + ).node.default_child.cfn_options.metadata = suppress_cloudformation_action() # add notification to the single-account pipeline single_account_pipeline.on_state_change( @@ -93,13 +111,15 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: ) ), ), - event_pattern=events.EventPattern(detail={"state": ["SUCCEEDED", "FAILED"]}), + event_pattern=events.EventPattern( + detail={"state": ["SUCCEEDED", "FAILED"]} + ), ) single_account_pipeline.add_to_role_policy( iam.PolicyStatement( actions=["events:PutEvents"], resources=[ - f"arn:{core.Aws.PARTITION}:events:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:event-bus/*", + f"arn:{Aws.PARTITION}:events:{Aws.REGION}:{Aws.ACCOUNT_ID}:event-bus/*", ], ) ) @@ -118,11 +138,11 @@ def __init__(self, scope: core.Construct, id: str, **kwargs) -> None: pipeline_permissions(single_account_pipeline, assets_bucket) # Outputs # - core.CfnOutput( + CfnOutput( self, id="Pipelines", value=( f"https://console.aws.amazon.com/codesuite/codepipeline/pipelines/" - f"{single_account_pipeline.pipeline_name}/view?region={core.Aws.REGION}" + f"{single_account_pipeline.pipeline_name}/view?region={Aws.REGION}" ), ) diff --git a/source/infrastructure/lib/blueprints/pipeline_definitions/__init__.py b/source/infrastructure/lib/blueprints/pipeline_definitions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/lib/blueprints/byom/pipeline_definitions/approval_actions.py b/source/infrastructure/lib/blueprints/pipeline_definitions/approval_actions.py similarity index 100% rename from source/lib/blueprints/byom/pipeline_definitions/approval_actions.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/approval_actions.py diff --git a/source/lib/blueprints/byom/pipeline_definitions/build_actions.py b/source/infrastructure/lib/blueprints/pipeline_definitions/build_actions.py similarity index 84% rename from source/lib/blueprints/byom/pipeline_definitions/build_actions.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/build_actions.py index fa29c22..11bdf0b 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/build_actions.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/build_actions.py @@ -11,13 +11,13 @@ # and limitations under the License. # # ##################################################################################################################### from aws_cdk import ( + Aws, aws_iam as iam, aws_codebuild as codebuild, aws_codepipeline as codepipeline, aws_codepipeline_actions as codepipeline_actions, - core, ) -from lib.blueprints.byom.pipeline_definitions.helpers import suppress_pipeline_policy +from lib.blueprints.pipeline_definitions.helpers import suppress_pipeline_policy def build_action(scope, ecr_repository_name, image_tag, source_output): @@ -30,7 +30,11 @@ def build_action(scope, ecr_repository_name, image_tag, source_output): :return: codepipeline action in a form of a CDK object that can be attached to a codepipeline stage """ - codebuild_role = iam.Role(scope, "codebuildRole", assumed_by=iam.ServicePrincipal("codebuild.amazonaws.com")) + codebuild_role = iam.Role( + scope, + "codebuildRole", + assumed_by=iam.ServicePrincipal("codebuild.amazonaws.com"), + ) codebuild_policy = iam.PolicyStatement( actions=[ @@ -41,13 +45,17 @@ def build_action(scope, ecr_repository_name, image_tag, source_output): "ecr:UploadLayerPart", ], resources=[ - f"arn:{core.Aws.PARTITION}:ecr:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:repository/{ecr_repository_name}", + f"arn:{Aws.PARTITION}:ecr:{Aws.REGION}:{Aws.ACCOUNT_ID}:repository/{ecr_repository_name}", ], ) codebuild_role.add_to_policy(codebuild_policy) - codebuild_role.add_to_policy(iam.PolicyStatement(actions=["ecr:GetAuthorizationToken"], resources=["*"])) - codebuild_role_child_nodes = codebuild_role.node.find_all() - codebuild_role_child_nodes[3].cfn_options.metadata = suppress_pipeline_policy() + codebuild_role.add_to_policy( + iam.PolicyStatement(actions=["ecr:GetAuthorizationToken"], resources=["*"]) + ) + # add suppression + codebuild_role.node.find_child( + "DefaultPolicy" + ).node.default_child.cfn_options.metadata = suppress_pipeline_policy() # codebuild setup for build stage container_factory_project = codebuild.PipelineProject( scope, @@ -92,11 +100,11 @@ def build_action(scope, ecr_repository_name, image_tag, source_output): } ), environment=codebuild.BuildEnvironment( - build_image=codebuild.LinuxBuildImage.STANDARD_4_0, + build_image=codebuild.LinuxBuildImage.STANDARD_6_0, compute_type=codebuild.ComputeType.SMALL, environment_variables={ - "AWS_DEFAULT_REGION": {"value": core.Aws.REGION}, - "AWS_ACCOUNT_ID": {"value": core.Aws.ACCOUNT_ID}, + "AWS_DEFAULT_REGION": {"value": Aws.REGION}, + "AWS_ACCOUNT_ID": {"value": Aws.ACCOUNT_ID}, "IMAGE_REPO_NAME": {"value": ecr_repository_name}, "IMAGE_TAG": {"value": image_tag}, }, @@ -110,5 +118,5 @@ def build_action(scope, ecr_repository_name, image_tag, source_output): input=source_output, outputs=[codepipeline.Artifact()], ) - container_uri = f"{core.Aws.ACCOUNT_ID}.dkr.ecr.{core.Aws.REGION}.amazonaws.com/{ecr_repository_name}:{image_tag}" + container_uri = f"{Aws.ACCOUNT_ID}.dkr.ecr.{Aws.REGION}.amazonaws.com/{ecr_repository_name}:{image_tag}" return build_action_definition, container_uri diff --git a/source/lib/blueprints/byom/pipeline_definitions/cdk_context_value.py b/source/infrastructure/lib/blueprints/pipeline_definitions/cdk_context_value.py similarity index 100% rename from source/lib/blueprints/byom/pipeline_definitions/cdk_context_value.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/cdk_context_value.py diff --git a/source/lib/blueprints/byom/pipeline_definitions/configure_multi_account.py b/source/infrastructure/lib/blueprints/pipeline_definitions/configure_multi_account.py similarity index 68% rename from source/lib/blueprints/byom/pipeline_definitions/configure_multi_account.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/configure_multi_account.py index c210679..141e415 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/configure_multi_account.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/configure_multi_account.py @@ -13,12 +13,14 @@ from aws_cdk import ( aws_iam as iam, ) -from lib.blueprints.byom.pipeline_definitions.iam_policies import ( +from lib.blueprints.pipeline_definitions.iam_policies import ( s3_policy_read, create_ecr_repo_policy, model_package_group_policy, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ParameteresFactory as pf +from lib.blueprints.pipeline_definitions.templates_parameters import ( + ParameteresFactory as pf, +) def configure_multi_account_parameters_permissions( @@ -54,11 +56,15 @@ def configure_multi_account_parameters_permissions( dev_org_id = pf.create_org_id_parameter(scope, "DevOrgId", account_type) # create staging parameters account_type = "staging" - staging_account_id = pf.create_account_id_parameter(scope, "StagingAccountId", account_type) + staging_account_id = pf.create_account_id_parameter( + scope, "StagingAccountId", account_type + ) staging_org_id = pf.create_org_id_parameter(scope, "StagingOrgId", account_type) # create production parameters account_type = "production" - prod_account_id = pf.create_account_id_parameter(scope, "ProdAccountId", account_type) + prod_account_id = pf.create_account_id_parameter( + scope, "ProdAccountId", account_type + ) prod_org_id = pf.create_org_id_parameter(scope, "ProdOrgId", account_type) principals = [ @@ -78,7 +84,10 @@ def configure_multi_account_parameters_permissions( # add permissions for other accounts to access the blueprint bucket blueprint_repository_bucket.add_to_resource_policy( s3_policy_read( - [blueprint_repository_bucket.bucket_arn, f"{blueprint_repository_bucket.bucket_arn}/*"], + [ + blueprint_repository_bucket.bucket_arn, + f"{blueprint_repository_bucket.bucket_arn}/*", + ], principals, ) ) @@ -91,18 +100,36 @@ def configure_multi_account_parameters_permissions( "ModelPackageGroupPolicy", model_package_group_policy( model_registry.model_package_group_name, - [dev_account_id.value_as_string, staging_account_id.value_as_string, prod_account_id.value_as_string], + [ + dev_account_id.value_as_string, + staging_account_id.value_as_string, + prod_account_id.value_as_string, + ], ), ) # add environment variables to orchestrator lambda function - orchestrator_lambda_function.add_environment(key="IS_DELEGATED_ADMIN", value=is_delegated_admin.value_as_string) - orchestrator_lambda_function.add_environment(key="DEV_ACCOUNT_ID", value=dev_account_id.value_as_string) - orchestrator_lambda_function.add_environment(key="DEV_ORG_ID", value=dev_org_id.value_as_string) - orchestrator_lambda_function.add_environment(key="STAGING_ACCOUNT_ID", value=staging_account_id.value_as_string) - orchestrator_lambda_function.add_environment(key="STAGING_ORG_ID", value=staging_org_id.value_as_string) - orchestrator_lambda_function.add_environment(key="PROD_ACCOUNT_ID", value=prod_account_id.value_as_string) - orchestrator_lambda_function.add_environment(key="PROD_ORG_ID", value=prod_org_id.value_as_string) + orchestrator_lambda_function.add_environment( + key="IS_DELEGATED_ADMIN", value=is_delegated_admin.value_as_string + ) + orchestrator_lambda_function.add_environment( + key="DEV_ACCOUNT_ID", value=dev_account_id.value_as_string + ) + orchestrator_lambda_function.add_environment( + key="DEV_ORG_ID", value=dev_org_id.value_as_string + ) + orchestrator_lambda_function.add_environment( + key="STAGING_ACCOUNT_ID", value=staging_account_id.value_as_string + ) + orchestrator_lambda_function.add_environment( + key="STAGING_ORG_ID", value=staging_org_id.value_as_string + ) + orchestrator_lambda_function.add_environment( + key="PROD_ACCOUNT_ID", value=prod_account_id.value_as_string + ) + orchestrator_lambda_function.add_environment( + key="PROD_ORG_ID", value=prod_org_id.value_as_string + ) # add parameters paramaters_list.extend( @@ -122,12 +149,24 @@ def configure_multi_account_parameters_permissions( f"{is_delegated_admin.logical_id}": { "default": "Are you using a delegated administrator account (AWS Organizations)?" }, - f"{dev_account_id.logical_id}": {"default": "Development Account ID (Required)"}, - f"{dev_org_id.logical_id}": {"default": "Development Account Organizational Unit ID (Required)"}, - f"{staging_account_id.logical_id}": {"default": "Staging Account ID (Required)"}, - f"{staging_org_id.logical_id}": {"default": "Staging Account Organizational Unit ID (Required)"}, - f"{prod_account_id.logical_id}": {"default": "Production Account ID (Required)"}, - f"{prod_org_id.logical_id}": {"default": "Production Account Organizational Unit ID (Required)"}, + f"{dev_account_id.logical_id}": { + "default": "Development Account ID (Required)" + }, + f"{dev_org_id.logical_id}": { + "default": "Development Account Organizational Unit ID (Required)" + }, + f"{staging_account_id.logical_id}": { + "default": "Staging Account ID (Required)" + }, + f"{staging_org_id.logical_id}": { + "default": "Staging Account Organizational Unit ID (Required)" + }, + f"{prod_account_id.logical_id}": { + "default": "Production Account ID (Required)" + }, + f"{prod_org_id.logical_id}": { + "default": "Production Account Organizational Unit ID (Required)" + }, } ) diff --git a/source/lib/blueprints/byom/pipeline_definitions/deploy_actions.py b/source/infrastructure/lib/blueprints/pipeline_definitions/deploy_actions.py similarity index 80% rename from source/lib/blueprints/byom/pipeline_definitions/deploy_actions.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/deploy_actions.py index 9a07690..ee14ffb 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/deploy_actions.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/deploy_actions.py @@ -10,6 +10,7 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### +from aws_cdk import Aws, Aspects, Duration, Fn, CustomResource, CfnCapabilities from aws_cdk import ( aws_iam as iam, aws_lambda as lambda_, @@ -17,16 +18,17 @@ aws_cloudformation as cloudformation, aws_events as events, aws_events_targets as targets, - core, ) -from lib.blueprints.byom.pipeline_definitions.helpers import ( +from lib.blueprints.pipeline_definitions.helpers import ( suppress_lambda_policies, suppress_pipeline_policy, add_logs_policy, ) -from lib.conditional_resource import ConditionalResources -from lib.blueprints.byom.pipeline_definitions.cdk_context_value import get_cdk_context_value -from lib.blueprints.byom.pipeline_definitions.iam_policies import ( +from lib.blueprints.aspects.conditional_resource import ConditionalResources +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from lib.blueprints.pipeline_definitions.iam_policies import ( create_service_role, sagemaker_baseline_job_policy, sagemaker_model_bias_explainability_baseline_job_policy, @@ -63,8 +65,10 @@ def sagemaker_layer(scope, blueprint_bucket): return lambda_.LayerVersion( scope, "sagemakerlayer", - code=lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/sagemaker_layer.zip"), - compatible_runtimes=[lambda_.Runtime.PYTHON_3_9], + code=lambda_.Code.from_bucket( + blueprint_bucket, "blueprints/lambdas/sagemaker_layer.zip" + ), + compatible_runtimes=[lambda_.Runtime.PYTHON_3_9, lambda_.Runtime.PYTHON_3_10], ) @@ -102,10 +106,10 @@ def batch_transform( list( set( [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", - f"arn:{core.Aws.PARTITION}:s3:::{batch_input_bucket}", - f"arn:{core.Aws.PARTITION}:s3:::{batch_inference_data}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", + f"arn:{Aws.PARTITION}:s3:::{batch_input_bucket}", + f"arn:{Aws.PARTITION}:s3:::{batch_inference_data}", ] ) ) @@ -113,7 +117,7 @@ def batch_transform( s3_write = s3_policy_write( [ - f"arn:{core.Aws.PARTITION}:s3:::{batch_job_output_location}/*", + f"arn:{Aws.PARTITION}:s3:::{batch_job_output_location}/*", ] ) @@ -137,11 +141,13 @@ def batch_transform( batch_transform_lambda = lambda_.Function( scope, id, - runtime=lambda_.Runtime.PYTHON_3_9, + runtime=lambda_.Runtime.PYTHON_3_10, handler=lambda_handler, layers=[sm_layer], role=lambda_role, - code=lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/batch_transform.zip"), + code=lambda_.Code.from_bucket( + blueprint_bucket, "blueprints/lambdas/batch_transform.zip" + ), environment={ "model_name": model_name, "inference_instance": inference_instance, @@ -153,7 +159,9 @@ def batch_transform( }, ) - batch_transform_lambda.node.default_child.cfn_options.metadata = suppress_lambda_policies() + batch_transform_lambda.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) return batch_transform_lambda @@ -219,31 +227,33 @@ def create_baseline_job_lambda( :bias_config: Config object related to bias configurations of the input dataset. Required for ModelBias monitor :shap_config: Config of the Shap explainability. Used by ModelExplainability monitor :model_scores: Index or JSONPath location in the model output for the predicted scores to be explained. - This is not required if the model output is a single score. + This is not required if the model output is a single s :return: codepipeline action in a form of a CDK object that can be attached to a codepipeline stage """ s3_read = s3_policy_read( [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", # give access to files used by different monitors - f"arn:{core.Aws.PARTITION}:s3:::{baseline_output_bucket}", - f"arn:{core.Aws.PARTITION}:s3:::{baseline_output_bucket}/*", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", # give access to files used by different monitors + f"arn:{Aws.PARTITION}:s3:::{baseline_output_bucket}", + f"arn:{Aws.PARTITION}:s3:::{baseline_output_bucket}/*", ] ) s3_write = s3_policy_write( [ - f"arn:{core.Aws.PARTITION}:s3:::{baseline_job_output_location}/*", + f"arn:{Aws.PARTITION}:s3:::{baseline_job_output_location}/*", ] ) create_baseline_job_policy = sagemaker_baseline_job_policy(baseline_job_name) - sagemaker_logs_policy = sagemaker_logs_metrics_policy_document(scope, "BaselineLogsMetrics") + sagemaker_logs_policy = sagemaker_logs_metrics_policy_document( + scope, "BaselineLogsMetrics" + ) # Kms Key permissions kms_policy = kms_policy_document(scope, "BaselineKmsPolicy", kms_key_arn) # add conditions to KMS and ECR policies - core.Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) + Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) # sagemaker tags permissions sagemaker_tags_policy = sagemaker_tags_policy_statement() @@ -261,7 +271,8 @@ def create_baseline_job_lambda( # create a trust relation to assume the Role sagemaker_role.add_to_policy( iam.PolicyStatement( - actions=["sts:AssumeRole"], resources=[sagemaker_role.role_arn] # NOSONAR: repeated for clarity + actions=["sts:AssumeRole"], # NOSONAR: repeated for clarity + resources=[sagemaker_role.role_arn], # NOSONAR: repeated for clarity ) ) # creating a role so that this lambda can create a baseline job @@ -278,15 +289,24 @@ def create_baseline_job_lambda( # add extra permissions for "ModelBias", "ModelExplainability" baselines if monitoring_type in ["ModelBias", "ModelExplainability"]: lambda_role.add_to_policy(baseline_lambda_get_model_name_policy(endpoint_name)) - sagemaker_role.add_to_policy(baseline_lambda_get_model_name_policy(endpoint_name)) - sagemaker_role.add_to_policy(sagemaker_model_bias_explainability_baseline_job_policy()) + sagemaker_role.add_to_policy( + baseline_lambda_get_model_name_policy(endpoint_name) + ) + sagemaker_role.add_to_policy( + sagemaker_model_bias_explainability_baseline_job_policy() + ) sagemaker_role.add_to_policy(s3_read) sagemaker_role.add_to_policy(s3_write) - sagemaker_role_nodes = sagemaker_role.node.find_all() - sagemaker_role_nodes[2].node.default_child.cfn_options.metadata = suppress_pipeline_policy() + + # add suppression + sagemaker_role.node.find_child( + "DefaultPolicy" + ).node.default_child.cfn_options.metadata = suppress_pipeline_policy() + lambda_role.add_to_policy( iam.PolicyStatement( - actions=["iam:PassRole"], resources=[sagemaker_role.role_arn] # NOSONAR: repeated for clarity + actions=["iam:PassRole"], # NOSONAR: repeated for clarity + resources=[sagemaker_role.role_arn], # NOSONAR: repeated for clarity ) ) lambda_role.add_to_policy(create_baseline_job_policy) @@ -312,10 +332,10 @@ def create_baseline_job_lambda( "ENDPOINT_NAME": endpoint_name, "MODEL_PREDICTED_LABEL_CONFIG": model_predicted_label_config if model_predicted_label_config - else core.Aws.NO_VALUE, - "BIAS_CONFIG": bias_config if bias_config else core.Aws.NO_VALUE, - "SHAP_CONFIG": shap_config if shap_config else core.Aws.NO_VALUE, - "MODEL_SCORES": model_scores if model_scores else core.Aws.NO_VALUE, + else Aws.NO_VALUE, + "BIAS_CONFIG": bias_config if bias_config else Aws.NO_VALUE, + "SHAP_CONFIG": shap_config if shap_config else Aws.NO_VALUE, + "MODEL_SCORES": model_scores if model_scores else Aws.NO_VALUE, "STACK_NAME": stack_name, "LOG_LEVEL": "INFO", } @@ -334,19 +354,25 @@ def create_baseline_job_lambda( create_baseline_job_lambda = lambda_.Function( scope, "create_data_baseline_job", - runtime=lambda_.Runtime.PYTHON_3_9, + runtime=lambda_.Runtime.PYTHON_3_10, handler=lambda_handler, role=lambda_role, - code=lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/create_baseline_job.zip"), + code=lambda_.Code.from_bucket( + blueprint_bucket, "blueprints/lambdas/create_baseline_job.zip" + ), layers=[sm_layer], environment=lambda_environment_variables, - timeout=core.Duration.minutes(10), + timeout=Duration.minutes(10), ) - create_baseline_job_lambda.node.default_child.cfn_options.metadata = suppress_lambda_policies() - role_child_nodes = create_baseline_job_lambda.role.node.find_all() - role_child_nodes[2].node.default_child.cfn_options.metadata = suppress_pipeline_policy() + create_baseline_job_lambda.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) + # add suppression + create_baseline_job_lambda.role.node.find_child( + "DefaultPolicy" + ).node.default_child.cfn_options.metadata = suppress_pipeline_policy() return create_baseline_job_lambda @@ -391,22 +417,30 @@ def create_stackset_action( # cloudformation stackset permissions # get the account_id based on whether a delegated admin account or management account is used - account_id = core.Fn.condition_if( + account_id = Fn.condition_if( delegated_admin_condition.logical_id, "*", # used when a delegated admin account is used (i.e., the management account_id is not known) - core.Aws.ACCOUNT_ID, # used when the management account is used + Aws.ACCOUNT_ID, # used when the management account is used ).to_string() - cloudformation_stackset_permissions = cloudformation_stackset_policy(stack_name, account_id) - cloudformation_stackset_instances_permissions = cloudformation_stackset_instances_policy(stack_name, account_id) + cloudformation_stackset_permissions = cloudformation_stackset_policy( + stack_name, account_id + ) + cloudformation_stackset_instances_permissions = ( + cloudformation_stackset_instances_policy(stack_name, account_id) + ) lambda_role.add_to_policy(cloudformation_stackset_permissions) lambda_role.add_to_policy(cloudformation_stackset_instances_permissions) add_logs_policy(lambda_role) # add delegated admin account policy - delegated_admin_policy = delegated_admin_policy_document(scope, f"{action_name}DelegatedAdminPolicy") + delegated_admin_policy = delegated_admin_policy_document( + scope, f"{action_name}DelegatedAdminPolicy" + ) # create only if a delegated admin account is used - core.Aspects.of(delegated_admin_policy).add(ConditionalResources(delegated_admin_condition)) + Aspects.of(delegated_admin_policy).add( + ConditionalResources(delegated_admin_condition) + ) # attached the policy to the role delegated_admin_policy.attach_to_role(lambda_role) @@ -414,20 +448,29 @@ def create_stackset_action( create_update_cf_stackset_lambda = lambda_.Function( scope, f"{action_name}_stackset_lambda", - runtime=lambda_.Runtime.PYTHON_3_9, + runtime=lambda_.Runtime.PYTHON_3_10, handler="main.lambda_handler", role=lambda_role, - code=lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/create_update_cf_stackset.zip"), - timeout=core.Duration.minutes(15), + code=lambda_.Code.from_bucket( + blueprint_bucket, "blueprints/lambdas/create_update_cf_stackset.zip" + ), + timeout=Duration.minutes(15), # setup the CallAS for CF StackSet environment={ - "CALL_AS": core.Fn.condition_if(delegated_admin_condition.logical_id, "DELEGATED_ADMIN", "SELF").to_string() + "CALL_AS": Fn.condition_if( + delegated_admin_condition.logical_id, "DELEGATED_ADMIN", "SELF" + ).to_string() }, ) - create_update_cf_stackset_lambda.node.default_child.cfn_options.metadata = suppress_lambda_policies() - role_child_nodes = create_update_cf_stackset_lambda.role.node.find_all() - role_child_nodes[2].node.default_child.cfn_options.metadata = suppress_pipeline_policy() + create_update_cf_stackset_lambda.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) + + # add suppression + create_update_cf_stackset_lambda.role.node.find_child( + "DefaultPolicy" + ).node.default_child.cfn_options.metadata = suppress_pipeline_policy() # Create codepipeline action create_stackset_action = codepipeline_actions.LambdaInvokeAction( @@ -450,7 +493,12 @@ def create_stackset_action( def create_cloudformation_action( - action_name, stack_name, source_output, template_file, template_parameters_file, run_order=1 + action_name, + stack_name, + source_output, + template_file, + template_parameters_file, + run_order=1, ): """ create_cloudformation_action a CloudFormation action to be added to AWS Codepipeline stage @@ -467,7 +515,7 @@ def create_cloudformation_action( create_cloudformation_action = codepipeline_actions.CloudFormationCreateUpdateStackAction( action_name=action_name, stack_name=stack_name, - capabilities=[cloudformation.CloudFormationCapabilities.NAMED_IAM], + cfn_capabilities=[CfnCapabilities.NAMED_IAM], template_path=source_output.at_path(template_file), # Admin permissions are added to the deployment role used by the CF action for simplicity # and deploy different resources by different MLOps pipelines. Roles are defined by the @@ -505,10 +553,13 @@ def create_invoke_lambda_custom_resource( custom_resource_lambda_fn = lambda_.Function( scope, id, - code=lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/invoke_lambda_custom_resource.zip"), + code=lambda_.Code.from_bucket( + blueprint_bucket, + "blueprints/lambdas/invoke_lambda_custom_resource.zip", + ), handler="index.handler", - runtime=lambda_.Runtime.PYTHON_3_9, - timeout=core.Duration.minutes(5), + runtime=lambda_.Runtime.PYTHON_3_10, + timeout=Duration.minutes(5), ) custom_resource_lambda_fn.add_to_role_policy( @@ -519,9 +570,11 @@ def create_invoke_lambda_custom_resource( resources=[lambda_function_arn], ) ) - custom_resource_lambda_fn.node.default_child.cfn_options.metadata = suppress_lambda_policies() + custom_resource_lambda_fn.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) - invoke_lambda_custom_resource = core.CustomResource( + invoke_lambda_custom_resource = CustomResource( scope, f"{id}CustomResource", service_token=custom_resource_lambda_fn.function_arn, @@ -546,7 +599,7 @@ def create_copy_assets_lambda(scope, blueprint_repository_bucket_name): :return: CDK Lambda Function """ # if you're building the solution locally, replace source_bucket and file_key with your values - source_bucket = f"{get_cdk_context_value(scope, 'SourceBucket')}-{core.Aws.REGION}" + source_bucket = f"{get_cdk_context_value(scope, 'SourceBucket')}-{Aws.REGION}" file_key = ( f"{get_cdk_context_value(scope,'SolutionName')}/{get_cdk_context_value(scope,'Version')}/" f"{get_cdk_context_value(scope,'BlueprintsFile')}" @@ -555,9 +608,9 @@ def create_copy_assets_lambda(scope, blueprint_repository_bucket_name): custom_resource_lambda_fn = lambda_.Function( scope, "CustomResourceLambda", - code=lambda_.Code.from_asset("lambdas/custom_resource"), + code=lambda_.Code.from_asset("../lambdas/custom_resource"), handler="index.on_event", - runtime=lambda_.Runtime.PYTHON_3_9, + runtime=lambda_.Runtime.PYTHON_3_10, memory_size=256, environment={ "SOURCE_BUCKET": source_bucket, @@ -565,14 +618,19 @@ def create_copy_assets_lambda(scope, blueprint_repository_bucket_name): "DESTINATION_BUCKET": blueprint_repository_bucket_name, "LOG_LEVEL": "INFO", }, - timeout=core.Duration.minutes(10), + timeout=Duration.minutes(10), ) - custom_resource_lambda_fn.node.default_child.cfn_options.metadata = suppress_lambda_policies() + custom_resource_lambda_fn.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) # grant permission to download the file from the source bucket custom_resource_lambda_fn.add_to_role_policy( s3_policy_read( - [f"arn:{core.Aws.PARTITION}:s3:::{source_bucket}", f"arn:{core.Aws.PARTITION}:s3:::{source_bucket}/*"] + [ + f"arn:{Aws.PARTITION}:s3:::{source_bucket}", + f"arn:{Aws.PARTITION}:s3:::{source_bucket}/*", + ] ) ) @@ -590,10 +648,10 @@ def create_solution_helper(scope): helper_function = lambda_.Function( scope, "SolutionHelper", - code=lambda_.Code.from_asset("lambdas/solution_helper"), + code=lambda_.Code.from_asset("../lambdas/solution_helper"), handler="lambda_function.handler", - runtime=lambda_.Runtime.PYTHON_3_9, - timeout=core.Duration.minutes(5), + runtime=lambda_.Runtime.PYTHON_3_10, + timeout=Duration.minutes(5), ) helper_function.node.default_child.cfn_options.metadata = suppress_lambda_policies() @@ -611,7 +669,7 @@ def create_uuid_custom_resource(scope, create_model_registry, helper_function_ar :return: CDK Custom Resource """ - return core.CustomResource( + return CustomResource( scope, "CreateUniqueID", service_token=helper_function_arn, @@ -633,12 +691,12 @@ def create_send_data_custom_resource(scope, helper_function_arn, properties): :return: CDK Custom Resource """ - return core.CustomResource( + return CustomResource( scope, - "SendAnonymousData", + "SendAnonymizedData", service_token=helper_function_arn, properties=properties, - resource_type="Custom::AnonymousData", + resource_type="Custom::AnonymizedData", ) @@ -688,14 +746,14 @@ def autopilot_training_job( """ s3_read = s3_policy_read( [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", ] ) s3_write = s3_policy_write( [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/{job_output_location}/*", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/{job_output_location}/*", ] ) @@ -706,9 +764,11 @@ def autopilot_training_job( # Kms Key permissions kms_policy = kms_policy_document(scope, "AutopilotKmsPolicy", kms_key_arn) # add conditions to KMS and ECR policies - core.Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) + Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) - sagemaker_logs_policy = sagemaker_logs_metrics_policy_document(scope, "AutopilotLogsMetrics") + sagemaker_logs_policy = sagemaker_logs_metrics_policy_document( + scope, "AutopilotLogsMetrics" + ) # create sagemaker role sagemaker_role = create_service_role( scope, @@ -726,7 +786,11 @@ def autopilot_training_job( sagemaker_role.add_to_policy(s3_write) # create a trust relation to assume the Role - sagemaker_role.add_to_policy(iam.PolicyStatement(actions=["sts:AssumeRole"], resources=[sagemaker_role.role_arn])) + sagemaker_role.add_to_policy( + iam.PolicyStatement( + actions=["sts:AssumeRole"], resources=[sagemaker_role.role_arn] + ) + ) lambda_role = create_service_role( scope, @@ -736,17 +800,24 @@ def autopilot_training_job( ) lambda_role.add_to_policy(autopilot_job_permissions) - lambda_role.add_to_policy(iam.PolicyStatement(actions=["iam:PassRole"], resources=[sagemaker_role.role_arn])) + lambda_role.add_to_policy( + iam.PolicyStatement( + actions=["iam:PassRole"], resources=[sagemaker_role.role_arn] + ) + ) add_logs_policy(lambda_role) autopilot_lambda = lambda_.Function( scope, id, - runtime=lambda_.Runtime.PYTHON_3_9, + runtime=lambda_.Runtime.PYTHON_3_10, handler=lambda_handler, layers=[sm_layer], role=lambda_role, - code=lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/create_sagemaker_autopilot_job.zip"), + code=lambda_.Code.from_bucket( + blueprint_bucket, + "blueprints/lambdas/create_sagemaker_autopilot_job.zip", + ), environment={ "JOB_NAME": job_name, "ROLE_ARN": sagemaker_role.role_arn, @@ -765,10 +836,12 @@ def autopilot_training_job( "GENERATE_CANDIDATE_DEFINITIONS_ONLY": generate_candidate_definitions_only, "LOG_LEVEL": "INFO", }, - timeout=core.Duration.minutes(10), + timeout=Duration.minutes(10), ) - autopilot_lambda.node.default_child.cfn_options.metadata = suppress_lambda_policies() + autopilot_lambda.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) return autopilot_lambda @@ -811,14 +884,14 @@ def model_training_job( """ s3_read = s3_policy_read( [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/*", ] ) s3_write = s3_policy_write( [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/{job_output_location}/*", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket.bucket_name}/{job_output_location}/*", ] ) @@ -828,9 +901,11 @@ def model_training_job( # Kms Key permissions kms_policy = kms_policy_document(scope, "TrainingKmsPolicy", kms_key_arn) # add conditions to KMS and ECR policies - core.Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) + Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) - sagemaker_logs_policy = sagemaker_logs_metrics_policy_document(scope, "TrainingLogsMetrics") + sagemaker_logs_policy = sagemaker_logs_metrics_policy_document( + scope, "TrainingLogsMetrics" + ) # create sagemaker role sagemaker_role = create_service_role( scope, @@ -847,7 +922,11 @@ def model_training_job( sagemaker_role.add_to_policy(s3_write) # create a trust relation to assume the Role - sagemaker_role.add_to_policy(iam.PolicyStatement(actions=["sts:AssumeRole"], resources=[sagemaker_role.role_arn])) + sagemaker_role.add_to_policy( + iam.PolicyStatement( + actions=["sts:AssumeRole"], resources=[sagemaker_role.role_arn] + ) + ) lambda_role = create_service_role( scope, @@ -857,17 +936,23 @@ def model_training_job( ) lambda_role.add_to_policy(training_job_permissions) - lambda_role.add_to_policy(iam.PolicyStatement(actions=["iam:PassRole"], resources=[sagemaker_role.role_arn])) + lambda_role.add_to_policy( + iam.PolicyStatement( + actions=["iam:PassRole"], resources=[sagemaker_role.role_arn] + ) + ) add_logs_policy(lambda_role) training_lambda = lambda_.Function( scope, id, - runtime=lambda_.Runtime.PYTHON_3_9, + runtime=lambda_.Runtime.PYTHON_3_10, handler=lambda_handler, layers=[sm_layer], role=lambda_role, - code=lambda_.Code.from_bucket(blueprint_bucket, "blueprints/byom/lambdas/create_model_training_job.zip"), + code=lambda_.Code.from_bucket( + blueprint_bucket, "blueprints/lambdas/create_model_training_job.zip" + ), environment={ "JOB_NAME": job_name, "ROLE_ARN": sagemaker_role.role_arn, @@ -893,11 +978,13 @@ def model_training_job( "USE_SPOT_INSTANCES": use_spot_instances, "MAX_WAIT_SECONDS": max_wait_time_for_spot, "HYPERPARAMETERS": hyperparameters, - "TUNER_CONFIG": tuner_config if tuner_config else core.Aws.NO_VALUE, - "HYPERPARAMETER_RANGES": hyperparameter_ranges if hyperparameter_ranges else core.Aws.NO_VALUE, + "TUNER_CONFIG": tuner_config if tuner_config else Aws.NO_VALUE, + "HYPERPARAMETER_RANGES": hyperparameter_ranges + if hyperparameter_ranges + else Aws.NO_VALUE, "LOG_LEVEL": "INFO", }, - timeout=core.Duration.minutes(10), + timeout=Duration.minutes(10), ) training_lambda.node.default_child.cfn_options.metadata = suppress_lambda_policies() @@ -905,7 +992,16 @@ def model_training_job( return training_lambda -def eventbridge_rule_to_sns(scope, logical_id, description, source, detail_type, detail, target_sns_topic, sns_message): +def eventbridge_rule_to_sns( + scope, + logical_id, + description, + source, + detail_type, + detail, + target_sns_topic, + sns_message, +): event_rule = events.Rule( scope, logical_id, diff --git a/source/lib/blueprints/byom/pipeline_definitions/helpers.py b/source/infrastructure/lib/blueprints/pipeline_definitions/helpers.py similarity index 77% rename from source/lib/blueprints/byom/pipeline_definitions/helpers.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/helpers.py index cdae713..22296db 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/helpers.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/helpers.py @@ -10,7 +10,7 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import aws_iam as iam, core +from aws_cdk import Aws, aws_iam as iam logs_str = ":logs:" @@ -33,30 +33,25 @@ def pipeline_permissions(pipeline, assets_bucket): ], resources=[ assets_bucket.arn_for_objects("*"), - "arn:" + core.Aws.PARTITION + ":lambda:" + core.Aws.REGION + ":" + core.Aws.ACCOUNT_ID + ":function:*", - "arn:" + core.Aws.PARTITION + logs_str + core.Aws.REGION + ":" + core.Aws.ACCOUNT_ID + ":log-group:*", + "arn:" + + Aws.PARTITION + + ":lambda:" + + Aws.REGION + + ":" + + Aws.ACCOUNT_ID + + ":function:*", + "arn:" + + Aws.PARTITION + + logs_str + + Aws.REGION + + ":" + + Aws.ACCOUNT_ID + + ":log-group:*", ], ) ) -def codepipeline_policy(): - """ - codepipeline_policy creates IAM policy statement that grants codepipeline interaction from a lambda function - that is invoked by codepipeline actions. - - :return: iam policy statement with PutJobSuccessResult and PutJobFailureResult permissions for CodePipeline - """ - return iam.PolicyStatement( - actions=[ - "codepipeline:PutJobSuccessResult", - "codepipeline:PutJobFailureResult", - ], - # IAM doesn't support PutJobSuccessResult and PutJobFailureResult actions to be bound to resources - resources=["*"], - ) - - def add_logs_policy(function_role): function_role.add_to_policy( iam.PolicyStatement( @@ -66,18 +61,18 @@ def add_logs_policy(function_role): ], resources=[ "arn:" - + core.Aws.PARTITION + + Aws.PARTITION + logs_str - + core.Aws.REGION + + Aws.REGION + ":" - + core.Aws.ACCOUNT_ID + + Aws.ACCOUNT_ID + ":log-group:/aws/lambda/*", "arn:" - + core.Aws.PARTITION + + Aws.PARTITION + logs_str - + core.Aws.REGION + + Aws.REGION + ":" - + core.Aws.ACCOUNT_ID + + Aws.ACCOUNT_ID + ":log-group:*:log-stream:*", ], ) @@ -85,7 +80,15 @@ def add_logs_policy(function_role): function_role.add_to_policy( iam.PolicyStatement( actions=["logs:CreateLogGroup"], - resources=["arn:" + core.Aws.PARTITION + logs_str + core.Aws.REGION + ":" + core.Aws.ACCOUNT_ID + ":*"], + resources=[ + "arn:" + + Aws.PARTITION + + logs_str + + Aws.REGION + + ":" + + Aws.ACCOUNT_ID + + ":*" + ], ) ) @@ -129,21 +132,6 @@ def suppress_s3_access_policy(): } -def suppress_assets_bucket(): - return { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W51", - "reason": ( - "This bucket does not need bucket policy. Permissions write to this bucket are set with IAM." - ), - } - ] - } - } - - def suppress_pipeline_bucket(): return { "cfn_nag": { @@ -248,19 +236,6 @@ def suppress_cloudformation_action(): } -def apply_secure_bucket_policy(bucket): - bucket.add_to_resource_policy( - iam.PolicyStatement( - sid="HttpsOnly", - effect=iam.Effect.DENY, - actions=["*"], - resources=[f"{bucket.bucket_arn}/*"], - principals=[iam.AnyPrincipal()], - conditions={"Bool": {"aws:SecureTransport": "false"}}, - ) - ) - - def suppress_lambda_policies(): return { "cfn_nag": { @@ -282,19 +257,6 @@ def suppress_lambda_policies(): } -def suppress_lambda_event_mapping(): - return { - "cfn_nag": { - "rules_to_suppress": [ - { - "id": "W12", - "reason": "IAM permissions, lambda:*EventSourceMapping can not be bound to specific resources.", - } - ] - } - } - - def suppress_delegated_admin_policy(): return { "cfn_nag": { diff --git a/source/lib/blueprints/byom/pipeline_definitions/iam_policies.py b/source/infrastructure/lib/blueprints/pipeline_definitions/iam_policies.py similarity index 80% rename from source/lib/blueprints/byom/pipeline_definitions/iam_policies.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/iam_policies.py index e4504aa..08753c1 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/iam_policies.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/iam_policies.py @@ -10,20 +10,22 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import aws_iam as iam, core -from lib.blueprints.byom.pipeline_definitions.helpers import ( +from aws_cdk import aws_iam as iam, Fn, Aws +from lib.blueprints.pipeline_definitions.helpers import ( suppress_ecr_policy, suppress_cloudwatch_policy, suppress_delegated_admin_policy, ) -sagemaker_arn_prefix = core.Fn.sub( +sagemaker_arn_prefix = Fn.sub( "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", - {"PARTITION": core.Aws.PARTITION, "REGION": core.Aws.REGION, "ACCOUNT_ID": core.Aws.ACCOUNT_ID}, + {"PARTITION": Aws.PARTITION, "REGION": Aws.REGION, "ACCOUNT_ID": Aws.ACCOUNT_ID}, ) -def sagemaker_policy_statement(is_realtime_pipeline, endpoint_name, endpoint_name_provided): +def sagemaker_policy_statement( + is_realtime_pipeline, endpoint_name, endpoint_name_provided +): actions = [ "sagemaker:CreateModel", "sagemaker:DescribeModel", # NOSONAR: permission needs to be repeated for clarity @@ -47,8 +49,10 @@ def sagemaker_policy_statement(is_realtime_pipeline, endpoint_name, endpoint_nam ) # if a custom endpoint_name is provided, use it. Otherwise, use the generated name - endpoint = core.Fn.condition_if( - endpoint_name_provided.logical_id, endpoint_name.value_as_string, "mlopssagemakerendpoint*" + endpoint = Fn.condition_if( + endpoint_name_provided.logical_id, + endpoint_name.value_as_string, + "mlopssagemakerendpoint*", ).to_string() # extend resources and add @@ -107,7 +111,11 @@ def sagemaker_model_bias_explainability_baseline_job_policy(): def sagemaker_baseline_job_policy(baseline_job_name): return iam.PolicyStatement( effect=iam.Effect.ALLOW, - actions=["sagemaker:CreateProcessingJob", "sagemaker:DescribeProcessingJob", "sagemaker:StopProcessingJob"], + actions=[ + "sagemaker:CreateProcessingJob", + "sagemaker:DescribeProcessingJob", + "sagemaker:StopProcessingJob", + ], resources=[ f"{sagemaker_arn_prefix}:processing-job/{baseline_job_name}", ], @@ -120,7 +128,9 @@ def batch_transform_policy(): actions=[ "sagemaker:CreateTransformJob", ], - resources=[f"{sagemaker_arn_prefix}:transform-job/mlopssagemakermodel-*-batch-transform-*"], + resources=[ + f"{sagemaker_arn_prefix}:transform-job/mlopssagemakermodel-*-batch-transform-*" + ], ) @@ -174,7 +184,9 @@ def create_service_role(scope, id, service, description): ) -def sagemaker_monitor_policy_statement(baseline_job_name, monitoring_schedule_name, endpoint_name, monitoring_type): +def sagemaker_monitor_policy_statement( + baseline_job_name, monitoring_schedule_name, endpoint_name, monitoring_type +): # common permissions actions = [ "sagemaker:DescribeModel", @@ -234,7 +246,9 @@ def sagemaker_monitor_policy_statement(baseline_job_name, monitoring_schedule_na "sagemaker:DescribeModelExplainabilityJobDefinition", "sagemaker:DeleteModelExplainabilityJobDefinition", ], - "resources": [f"{sagemaker_arn_prefix}:model-explainability-job-definition/*"], + "resources": [ + f"{sagemaker_arn_prefix}:model-explainability-job-definition/*" + ], }, } # add monitoring type's specific permissions @@ -277,7 +291,7 @@ def sagemaker_logs_metrics_policy_document(scope, id): "logs:PutLogEvents", ], resources=[ - f"arn:{core.Aws.PARTITION}:logs:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:log-group:/aws/sagemaker/*" + f"arn:{Aws.PARTITION}:logs:{Aws.REGION}:{Aws.ACCOUNT_ID}:log-group:/aws/sagemaker/*" ], ), iam.PolicyStatement( @@ -294,18 +308,6 @@ def sagemaker_logs_metrics_policy_document(scope, id): return policy -def s3_policy_read_write(resources_list): - return iam.PolicyStatement( - effect=iam.Effect.ALLOW, - actions=[ - "s3:GetObject", - "s3:PutObject", # NOSONAR: permission needs to be repeated for clarity - "s3:ListBucket", - ], - resources=resources_list, - ) - - def s3_policy_read(resources_list, principals=None): return iam.PolicyStatement( effect=iam.Effect.ALLOW, @@ -451,7 +453,10 @@ def model_package_group_policy(model_package_group_name, accounts_list): "Sid": "AddPermModelPackageGroup", "Effect": "Allow", "Principal": { - "AWS": [f"arn:{core.Aws.PARTITION}:iam::{account_id}:root" for account_id in accounts_list] + "AWS": [ + f"arn:{Aws.PARTITION}:iam::{account_id}:root" + for account_id in accounts_list + ] }, "Action": actions, "Resource": resources, @@ -472,11 +477,11 @@ def cloudformation_stackset_policy(stack_name, account_id): # Stack sets with service-managed permissions are created in the management account, # including stack sets created by delegated administrators. # the "*" is used here for "ACCOUNT_ID" when a delegated administrator account - # is used by the solution (default). Otherwise, core.Aws.ACCOUNT_ID used. + # is used by the solution (default). Otherwise, Aws.ACCOUNT_ID used. # more info on CF StackSets with delegated admin account can be found here: # https://docs.amazonaws.cn/en_us/AWSCloudFormation/latest/UserGuide/stacksets-orgs-delegated-admin.html - f"arn:{core.Aws.PARTITION}:cloudformation:{core.Aws.REGION}:{account_id}:stackset/{stack_name}:*", - f"arn:{core.Aws.PARTITION}:cloudformation:*::type/resource/*", + f"arn:{Aws.PARTITION}:cloudformation:{Aws.REGION}:{account_id}:stackset/{stack_name}:*", + f"arn:{Aws.PARTITION}:cloudformation:*::type/resource/*", ], ) @@ -491,10 +496,10 @@ def cloudformation_stackset_instances_policy(stack_name, account_id): "lambda:TagResource", ], resources=[ - f"arn:{core.Aws.PARTITION}:cloudformation::{account_id}:stackset-target/{stack_name}:*", - f"arn:{core.Aws.PARTITION}:cloudformation:{core.Aws.REGION}::type/resource/*", - f"arn:{core.Aws.PARTITION}:cloudformation:{core.Aws.REGION}:{account_id}:stackset/{stack_name}:*", - f"arn:{core.Aws.PARTITION}:lambda:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:function:*", + f"arn:{Aws.PARTITION}:cloudformation::{account_id}:stackset-target/{stack_name}:*", + f"arn:{Aws.PARTITION}:cloudformation:{Aws.REGION}::type/resource/*", + f"arn:{Aws.PARTITION}:cloudformation:{Aws.REGION}:{account_id}:stackset/{stack_name}:*", + f"arn:{Aws.PARTITION}:lambda:{Aws.REGION}:{Aws.ACCOUNT_ID}:function:*", ], ) @@ -512,7 +517,9 @@ def delegated_admin_policy_document(scope, id): ], ) # add supression for * - delegated_admin_policy.node.default_child.cfn_options.metadata = suppress_delegated_admin_policy() + delegated_admin_policy.node.default_child.cfn_options.metadata = ( + suppress_delegated_admin_policy() + ) return delegated_admin_policy @@ -539,8 +546,8 @@ def create_orchestrator_policy( ], resources=[ ( - f"arn:{core.Aws.PARTITION}:cloudformation:{core.Aws.REGION}:" - f"{core.Aws.ACCOUNT_ID}:stack/{pipeline_stack_name}*/*" + f"arn:{Aws.PARTITION}:cloudformation:{Aws.REGION}:" + f"{Aws.ACCOUNT_ID}:stack/{pipeline_stack_name}*/*" ), ], ), @@ -559,7 +566,9 @@ def create_orchestrator_policy( "iam:UntagRole", "iam:TagRole", ], - resources=[f"arn:{core.Aws.PARTITION}:iam::{core.Aws.ACCOUNT_ID}:role/{pipeline_stack_name}*"], + resources=[ + f"arn:{Aws.PARTITION}:iam::{Aws.ACCOUNT_ID}:role/{pipeline_stack_name}*" + ], ), iam.PolicyStatement( effect=iam.Effect.ALLOW, @@ -569,8 +578,8 @@ def create_orchestrator_policy( ], resources=[ ( - f"arn:{core.Aws.PARTITION}:ecr:{core.Aws.REGION}:" - f"{core.Aws.ACCOUNT_ID}:repository/{ecr_repo_name}" + f"arn:{Aws.PARTITION}:ecr:{Aws.REGION}:" + f"{Aws.ACCOUNT_ID}:repository/{ecr_repo_name}" ) ], ), @@ -583,14 +592,14 @@ def create_orchestrator_policy( ], resources=[ ( - f"arn:{core.Aws.PARTITION}:codebuild:{core.Aws.REGION}:" - f"{core.Aws.ACCOUNT_ID}:project/ContainerFactory*" + f"arn:{Aws.PARTITION}:codebuild:{Aws.REGION}:" + f"{Aws.ACCOUNT_ID}:project/ContainerFactory*" ), ( - f"arn:{core.Aws.PARTITION}:codebuild:{core.Aws.REGION}:" - f"{core.Aws.ACCOUNT_ID}:project/VerifySagemaker*" + f"arn:{Aws.PARTITION}:codebuild:{Aws.REGION}:" + f"{Aws.ACCOUNT_ID}:project/VerifySagemaker*" ), - f"arn:{core.Aws.PARTITION}:codebuild:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:report-group/*", + f"arn:{Aws.PARTITION}:codebuild:{Aws.REGION}:{Aws.ACCOUNT_ID}:report-group/*", ], ), iam.PolicyStatement( @@ -607,20 +616,43 @@ def create_orchestrator_policy( "lambda:AddPermission", "lambda:RemovePermission", "lambda:UpdateFunctionConfiguration", + "lambda:TagResource", ], resources=[ - f"arn:{core.Aws.PARTITION}:lambda:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:layer:*", - f"arn:{core.Aws.PARTITION}:lambda:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:function:*", + f"arn:{Aws.PARTITION}:lambda:{Aws.REGION}:{Aws.ACCOUNT_ID}:layer:*", + f"arn:{Aws.PARTITION}:lambda:{Aws.REGION}:{Aws.ACCOUNT_ID}:function:*", ], ), s3_policy_read( [ blueprint_repository_bucket.bucket_arn, - f"arn:{core.Aws.PARTITION}:s3:::{assets_s3_bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_s3_bucket_name}", blueprint_repository_bucket.arn_for_objects("*"), - f"arn:{core.Aws.PARTITION}:s3:::{assets_s3_bucket_name}/*", + f"arn:{Aws.PARTITION}:s3:::{assets_s3_bucket_name}/*", ] ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "servicecatalog:CreateApplication", + "servicecatalog:GetApplication", + "servicecatalog:UpdateApplication", + "servicecatalog:DeleteApplication", + "servicecatalog:CreateAttributeGroup", + "servicecatalog:GetAttributeGroup", + "servicecatalog:UpdateAttributeGroup", + "servicecatalog:DeleteAttributeGroup", + "servicecatalog:AssociateResource", + "servicecatalog:DisassociateResource", + "servicecatalog:AssociateAttributeGroup", + "servicecatalog:DisassociateAttributeGroup", + "servicecatalog:TagResource", + ], + resources=[ + f"arn:{Aws.PARTITION}:servicecatalog:{Aws.REGION}:{Aws.ACCOUNT_ID}:/applications/*", + f"arn:{Aws.PARTITION}:servicecatalog:{Aws.REGION}:{Aws.ACCOUNT_ID}:/attribute-groups/*", + ], + ), iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=[ @@ -634,8 +666,8 @@ def create_orchestrator_policy( ], resources=[ ( - f"arn:{core.Aws.PARTITION}:codepipeline:{core.Aws.REGION}:" - f"{core.Aws.ACCOUNT_ID}:{pipeline_stack_name}*" + f"arn:{Aws.PARTITION}:codepipeline:{Aws.REGION}:" + f"{Aws.ACCOUNT_ID}:{pipeline_stack_name}*" ) ], ), @@ -649,11 +681,11 @@ def create_orchestrator_policy( "apigateway:PUT", ], resources=[ - f"arn:{core.Aws.PARTITION}:apigateway:{core.Aws.REGION}::/restapis/*", - f"arn:{core.Aws.PARTITION}:apigateway:{core.Aws.REGION}::/restapis", - f"arn:{core.Aws.PARTITION}:apigateway:{core.Aws.REGION}::/account", - f"arn:{core.Aws.PARTITION}:apigateway:{core.Aws.REGION}::/usageplans", - f"arn:{core.Aws.PARTITION}:apigateway:{core.Aws.REGION}::/usageplans/*", + f"arn:{Aws.PARTITION}:apigateway:{Aws.REGION}::/restapis/*", + f"arn:{Aws.PARTITION}:apigateway:{Aws.REGION}::/restapis", + f"arn:{Aws.PARTITION}:apigateway:{Aws.REGION}::/account", + f"arn:{Aws.PARTITION}:apigateway:{Aws.REGION}::/usageplans", + f"arn:{Aws.PARTITION}:apigateway:{Aws.REGION}::/usageplans/*", ], ), iam.PolicyStatement( @@ -663,7 +695,7 @@ def create_orchestrator_policy( "logs:DescribeLogGroups", ], resources=[ - f"arn:{core.Aws.PARTITION}:logs:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:log-group:*", + f"arn:{Aws.PARTITION}:logs:{Aws.REGION}:{Aws.ACCOUNT_ID}:log-group:*", ], ), iam.PolicyStatement( @@ -674,15 +706,18 @@ def create_orchestrator_policy( "s3:PutBucketVersioning", "s3:PutBucketPublicAccessBlock", "s3:PutBucketLogging", + "s3:GetBucketPolicy", + "s3:PutBucketPolicy", + "s3:DeleteBucketPolicy", ], - resources=[f"arn:{core.Aws.PARTITION}:s3:::*"], + resources=[f"arn:{Aws.PARTITION}:s3:::*"], ), iam.PolicyStatement( effect=iam.Effect.ALLOW, actions=[ "s3:PutObject", # NOSONAR: permission needs to be repeated for clarity ], - resources=[f"arn:{core.Aws.PARTITION}:s3:::{assets_s3_bucket_name}/*"], + resources=[f"arn:{Aws.PARTITION}:s3:::{assets_s3_bucket_name}/*"], ), iam.PolicyStatement( effect=iam.Effect.ALLOW, @@ -696,7 +731,7 @@ def create_orchestrator_policy( ], resources=[ ( - f"arn:{core.Aws.PARTITION}:sns:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:" + f"arn:{Aws.PARTITION}:sns:{Aws.REGION}:{Aws.ACCOUNT_ID}:" f"{pipeline_stack_name}*-*PipelineNotification*" ) ], @@ -712,8 +747,8 @@ def create_orchestrator_policy( "events:PutEvents", ], resources=[ - f"arn:{core.Aws.PARTITION}:events:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:rule/*", - f"arn:{core.Aws.PARTITION}:events:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:event-bus/*", + f"arn:{Aws.PARTITION}:events:{Aws.REGION}:{Aws.ACCOUNT_ID}:rule/*", + f"arn:{Aws.PARTITION}:events:{Aws.REGION}:{Aws.ACCOUNT_ID}:event-bus/*", ], ), # SageMaker Model Card permissions @@ -732,9 +767,9 @@ def create_orchestrator_policy( "sagemaker:DescribeTrainingJob", ], resources=[ - f"arn:{core.Aws.PARTITION}:sagemaker:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:model-card/*", - f"arn:{core.Aws.PARTITION}:sagemaker:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:model/*", - f"arn:{core.Aws.PARTITION}:sagemaker:{core.Aws.REGION}:{core.Aws.ACCOUNT_ID}:training-job/*", + f"arn:{Aws.PARTITION}:sagemaker:{Aws.REGION}:{Aws.ACCOUNT_ID}:model-card/*", + f"arn:{Aws.PARTITION}:sagemaker:{Aws.REGION}:{Aws.ACCOUNT_ID}:model/*", + f"arn:{Aws.PARTITION}:sagemaker:{Aws.REGION}:{Aws.ACCOUNT_ID}:training-job/*", ], ), iam.PolicyStatement( @@ -754,6 +789,8 @@ def create_orchestrator_policy( def create_invoke_lambda_policy(lambda_functions_list): return iam.PolicyStatement( effect=iam.Effect.ALLOW, - actions=["lambda:InvokeFunction"], # NOSONAR: permission needs to be repeated for clarity + actions=[ + "lambda:InvokeFunction" + ], # NOSONAR: permission needs to be repeated for clarity resources=lambda_functions_list, ) diff --git a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_endpoint.py b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_endpoint.py similarity index 100% rename from source/lib/blueprints/byom/pipeline_definitions/sagemaker_endpoint.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_endpoint.py diff --git a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_endpoint_config.py b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_endpoint_config.py similarity index 100% rename from source/lib/blueprints/byom/pipeline_definitions/sagemaker_endpoint_config.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_endpoint_config.py diff --git a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_model.py b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model.py similarity index 88% rename from source/lib/blueprints/byom/pipeline_definitions/sagemaker_model.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model.py index 4aecb67..7ef53e8 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_model.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model.py @@ -10,7 +10,7 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import aws_sagemaker as sagemaker, core +from aws_cdk import Aws, Fn, aws_sagemaker as sagemaker def create_sagemaker_model( @@ -35,16 +35,16 @@ def create_sagemaker_model( # else "image" and "modelDataUrl" must be provided # "image" and "modelDataUrl" will be ignored if "modelPackageName" is provided primary_container={ - "image": core.Fn.condition_if( - model_registry_provided.logical_id, core.Aws.NO_VALUE, algorithm_image_uri + "image": Fn.condition_if( + model_registry_provided.logical_id, Aws.NO_VALUE, algorithm_image_uri ).to_string(), - "modelDataUrl": core.Fn.condition_if( + "modelDataUrl": Fn.condition_if( model_registry_provided.logical_id, - core.Aws.NO_VALUE, + Aws.NO_VALUE, f"s3://{assets_bucket_name}/{model_artifact_location}", ).to_string(), - "modelPackageName": core.Fn.condition_if( - model_registry_provided.logical_id, model_package_name, core.Aws.NO_VALUE + "modelPackageName": Fn.condition_if( + model_registry_provided.logical_id, model_package_name, Aws.NO_VALUE ).to_string(), }, tags=[{"key": "model_name", "value": model_name}], diff --git a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_model_monitor_construct.py b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model_monitor_construct.py similarity index 92% rename from source/lib/blueprints/byom/pipeline_definitions/sagemaker_model_monitor_construct.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model_monitor_construct.py index 3f0a8b3..26ab81a 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_model_monitor_construct.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model_monitor_construct.py @@ -11,10 +11,11 @@ # and limitations under the License. # # ##################################################################################################################### from typing import List, Dict, Union, Optional -from aws_cdk import aws_sagemaker as sagemaker, core +from constructs import Construct +from aws_cdk import Token, Fn, aws_sagemaker as sagemaker -class SageMakerModelMonitor(core.Construct): +class SageMakerModelMonitor(Construct): """ Creates Amazon SageMaker Model Monitor (DataQuality or ModelQuality) @@ -51,7 +52,7 @@ class SageMakerModelMonitor(core.Construct): def __init__( self, # NOSONAR:S107 the class is designed to take many attributes - scope: core.Construct, + scope: Construct, id: str, monitoring_schedule_name: str, endpoint_name: str, @@ -101,7 +102,12 @@ def __init__( self.features_attribute = features_attribute # validate the provided monitoring_type - if monitoring_type not in ["DataQuality", "ModelQuality", "ModelBias", "ModelExplainability"]: + if monitoring_type not in [ + "DataQuality", + "ModelQuality", + "ModelBias", + "ModelExplainability", + ]: raise ValueError( ( f"The provided monitoring type: {monitoring_type} is not valid. " @@ -199,14 +205,14 @@ def _create_data_quality_job_definition( ), job_resources=sagemaker.CfnDataQualityJobDefinition.MonitoringResourcesProperty( cluster_config=sagemaker.CfnDataQualityJobDefinition.ClusterConfigProperty( - instance_count=core.Token.as_number(self.instance_count), + instance_count=Token.as_number(self.instance_count), instance_type=self.instance_type, - volume_size_in_gb=core.Token.as_number(self.instance_volume_size), + volume_size_in_gb=Token.as_number(self.instance_volume_size), volume_kms_key_id=self.kms_key_arn, ) ), stopping_condition=sagemaker.CfnDataQualityJobDefinition.StoppingConditionProperty( - max_runtime_in_seconds=core.Token.as_number(self.max_runtime_seconds) + max_runtime_in_seconds=Token.as_number(self.max_runtime_seconds) ), role_arn=self.role_arn, tags=self.tags, @@ -246,7 +252,9 @@ def _create_model_quality_job_definition( local_path="/opt/ml/processing/input/model_quality_input", inference_attribute=self.inference_attribute, probability_attribute=self.probability_attribute, - probability_threshold_attribute=core.Token.as_number(self.probability_threshold_attribute), + probability_threshold_attribute=Token.as_number( + self.probability_threshold_attribute + ), ), ground_truth_s3_input=sagemaker.CfnModelQualityJobDefinition.MonitoringGroundTruthS3InputProperty( s3_uri=self.ground_truth_s3_uri @@ -266,14 +274,14 @@ def _create_model_quality_job_definition( ), job_resources=sagemaker.CfnModelQualityJobDefinition.MonitoringResourcesProperty( cluster_config=sagemaker.CfnModelQualityJobDefinition.ClusterConfigProperty( - instance_count=core.Token.as_number(self.instance_count), + instance_count=Token.as_number(self.instance_count), instance_type=self.instance_type, - volume_size_in_gb=core.Token.as_number(self.instance_volume_size), + volume_size_in_gb=Token.as_number(self.instance_volume_size), volume_kms_key_id=self.kms_key_arn, ) ), stopping_condition=sagemaker.CfnModelQualityJobDefinition.StoppingConditionProperty( - max_runtime_in_seconds=core.Token.as_number(self.max_runtime_seconds) + max_runtime_in_seconds=Token.as_number(self.max_runtime_seconds) ), role_arn=self.role_arn, tags=self.tags, @@ -314,7 +322,9 @@ def _create_model_bias_job_definition( features_attribute=self.features_attribute, inference_attribute=self.inference_attribute, probability_attribute=self.probability_attribute, - probability_threshold_attribute=core.Token.as_number(self.probability_threshold_attribute), + probability_threshold_attribute=Token.as_number( + self.probability_threshold_attribute + ), ), ground_truth_s3_input=sagemaker.CfnModelBiasJobDefinition.MonitoringGroundTruthS3InputProperty( s3_uri=self.ground_truth_s3_uri @@ -334,14 +344,14 @@ def _create_model_bias_job_definition( ), job_resources=sagemaker.CfnModelBiasJobDefinition.MonitoringResourcesProperty( cluster_config=sagemaker.CfnModelBiasJobDefinition.ClusterConfigProperty( - instance_count=core.Token.as_number(self.instance_count), + instance_count=Token.as_number(self.instance_count), instance_type=self.instance_type, - volume_size_in_gb=core.Token.as_number(self.instance_volume_size), + volume_size_in_gb=Token.as_number(self.instance_volume_size), volume_kms_key_id=self.kms_key_arn, ) ), stopping_condition=sagemaker.CfnModelBiasJobDefinition.StoppingConditionProperty( - max_runtime_in_seconds=core.Token.as_number(self.max_runtime_seconds) + max_runtime_in_seconds=Token.as_number(self.max_runtime_seconds) ), role_arn=self.role_arn, tags=self.tags, @@ -367,7 +377,8 @@ def _create_model_explainability_job_definition( self.scope, id, model_explainability_app_specification=sagemaker.CfnModelExplainabilityJobDefinition.ModelExplainabilityAppSpecificationProperty( - config_uri=f"{self.baseline_job_output_location}/monitor/analysis_config.json", image_uri=self.image_uri + config_uri=f"{self.baseline_job_output_location}/monitor/analysis_config.json", + image_uri=self.image_uri, ), model_explainability_baseline_config=sagemaker.CfnModelExplainabilityJobDefinition.ModelExplainabilityBaselineConfigProperty( constraints_resource=sagemaker.CfnModelExplainabilityJobDefinition.ConstraintsResourceProperty( @@ -397,14 +408,14 @@ def _create_model_explainability_job_definition( ), job_resources=sagemaker.CfnModelExplainabilityJobDefinition.MonitoringResourcesProperty( cluster_config=sagemaker.CfnModelExplainabilityJobDefinition.ClusterConfigProperty( - instance_count=core.Token.as_number(self.instance_count), + instance_count=Token.as_number(self.instance_count), instance_type=self.instance_type, - volume_size_in_gb=core.Token.as_number(self.instance_volume_size), + volume_size_in_gb=Token.as_number(self.instance_volume_size), volume_kms_key_id=self.kms_key_arn, ) ), stopping_condition=sagemaker.CfnModelExplainabilityJobDefinition.StoppingConditionProperty( - max_runtime_in_seconds=core.Token.as_number(self.max_runtime_seconds) + max_runtime_in_seconds=Token.as_number(self.max_runtime_seconds) ), role_arn=self.role_arn, tags=self.tags, @@ -447,7 +458,7 @@ def _create_sagemaker_monitoring_schedule( # *JobDefinition's name is not specified, so stack updates won't fail # hence, "monitor_job_definition.job_definition_name" has no value. # The get_att is used to get the generated *JobDefinition's name - monitoring_job_definition_name=core.Fn.get_att( + monitoring_job_definition_name=Fn.get_att( monitor_job_definition.logical_id, "JobDefinitionName" ).to_string(), monitoring_type=self.monitoring_type, @@ -456,7 +467,7 @@ def _create_sagemaker_monitoring_schedule( ) # add dependency on teh monitor job defintion - schedule.add_depends_on(monitor_job_definition) + schedule.add_dependency(monitor_job_definition) return schedule diff --git a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_model_registry.py b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model_registry.py similarity index 91% rename from source/lib/blueprints/byom/pipeline_definitions/sagemaker_model_registry.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model_registry.py index 0d12e53..c1ed012 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_model_registry.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_model_registry.py @@ -10,7 +10,7 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import aws_sagemaker as sagemaker, core +from aws_cdk import Aws, RemovalPolicy, aws_sagemaker as sagemaker def create_sagemaker_model_registry(scope, id, model_package_group_name): @@ -28,10 +28,10 @@ def create_sagemaker_model_registry(scope, id, model_package_group_name): id, model_package_group_name=model_package_group_name, model_package_group_description="SageMaker model package group name (model registry) for mlops", - tags=[{"key": "stack-name", "value": core.Aws.STACK_NAME}], + tags=[{"key": "stack-name", "value": Aws.STACK_NAME}], ) # add update/deletion policy - model_registry.apply_removal_policy(core.RemovalPolicy.RETAIN) + model_registry.apply_removal_policy(RemovalPolicy.RETAIN) return model_registry diff --git a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_monitor_role.py b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_monitor_role.py similarity index 77% rename from source/lib/blueprints/byom/pipeline_definitions/sagemaker_monitor_role.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_monitor_role.py index 9ddbdad..a825333 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_monitor_role.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_monitor_role.py @@ -10,13 +10,10 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import ( - aws_iam as iam, - core, -) -from lib.conditional_resource import ConditionalResources +from aws_cdk import Aspects, Aws, aws_iam as iam +from lib.blueprints.aspects.conditional_resource import ConditionalResources -from lib.blueprints.byom.pipeline_definitions.iam_policies import ( +from lib.blueprints.pipeline_definitions.iam_policies import ( kms_policy_document, sagemaker_monitor_policy_statement, sagemaker_tags_policy_statement, @@ -50,10 +47,12 @@ def create_sagemaker_monitor_role( kms_policy = kms_policy_document(scope, "MLOpsKmsPolicy", kms_key_arn) # add conditions to KMS and ECR policies - core.Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) + Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) # create sagemaker role - role = iam.Role(scope, id, assumed_by=iam.ServicePrincipal("sagemaker.amazonaws.com")) + role = iam.Role( + scope, id, assumed_by=iam.ServicePrincipal("sagemaker.amazonaws.com") + ) # permissions to create sagemaker resources sagemaker_policy = sagemaker_monitor_policy_statement( @@ -63,17 +62,19 @@ def create_sagemaker_monitor_role( # sagemaker tags permissions sagemaker_tags_policy = sagemaker_tags_policy_statement() # logs/metrics permissions - logs_metrics_policy = sagemaker_logs_metrics_policy_document(scope, "SagemakerLogsMetricsPolicy") + logs_metrics_policy = sagemaker_logs_metrics_policy_document( + scope, "SagemakerLogsMetricsPolicy" + ) # S3 permissions s3_read_resources = list( set( # set is used since a same bucket can be used more than once [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket_name}", - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket_name}/*", - f"arn:{core.Aws.PARTITION}:s3:::{data_capture_bucket}", - f"arn:{core.Aws.PARTITION}:s3:::{data_capture_s3_location}/*", - f"arn:{core.Aws.PARTITION}:s3:::{baseline_output_bucket}", - f"arn:{core.Aws.PARTITION}:s3:::{baseline_job_output_location}/*", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket_name}/*", + f"arn:{Aws.PARTITION}:s3:::{data_capture_bucket}", + f"arn:{Aws.PARTITION}:s3:::{data_capture_s3_location}/*", + f"arn:{Aws.PARTITION}:s3:::{baseline_output_bucket}", + f"arn:{Aws.PARTITION}:s3:::{baseline_job_output_location}/*", ] ) ) @@ -82,14 +83,14 @@ def create_sagemaker_monitor_role( if model_monitor_ground_truth_bucket: s3_read_resources.extend( [ - f"arn:{core.Aws.PARTITION}:s3:::{model_monitor_ground_truth_bucket}", - f"arn:{core.Aws.PARTITION}:s3:::{model_monitor_ground_truth_input}/*" + f"arn:{Aws.PARTITION}:s3:::{model_monitor_ground_truth_bucket}", + f"arn:{Aws.PARTITION}:s3:::{model_monitor_ground_truth_input}/*", ] ) s3_read = s3_policy_read(s3_read_resources) s3_write = s3_policy_write( [ - f"arn:{core.Aws.PARTITION}:s3:::{output_s3_location}/*", + f"arn:{Aws.PARTITION}:s3:::{output_s3_location}/*", ] ) # IAM PassRole permission diff --git a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_role.py b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_role.py similarity index 75% rename from source/lib/blueprints/byom/pipeline_definitions/sagemaker_role.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_role.py index fa93037..a457a98 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/sagemaker_role.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/sagemaker_role.py @@ -10,13 +10,10 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import ( - aws_iam as iam, - core, -) -from lib.conditional_resource import ConditionalResources +from aws_cdk import Aspects, Aws, aws_iam as iam +from lib.blueprints.aspects.conditional_resource import ConditionalResources -from lib.blueprints.byom.pipeline_definitions.iam_policies import ( +from lib.blueprints.pipeline_definitions.iam_policies import ( ecr_policy_document, kms_policy_document, sagemaker_policy_statement, @@ -51,18 +48,26 @@ def create_sagemaker_role( # create optional policies ecr_policy = ecr_policy_document(scope, "MLOpsECRPolicy", custom_algorithms_ecr_arn) kms_policy = kms_policy_document(scope, "MLOpsKmsPolicy", kms_key_arn) - model_registry = model_registry_policy_document(scope, "ModelRegistryPolicy", model_package_group_name) + model_registry = model_registry_policy_document( + scope, "ModelRegistryPolicy", model_package_group_name + ) # add conditions to KMS and ECR policies - core.Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) - core.Aspects.of(ecr_policy).add(ConditionalResources(ecr_repo_arn_provided_condition)) - core.Aspects.of(model_registry).add(ConditionalResources(model_registry_provided_condition)) + Aspects.of(kms_policy).add(ConditionalResources(kms_key_arn_provided_condition)) + Aspects.of(ecr_policy).add(ConditionalResources(ecr_repo_arn_provided_condition)) + Aspects.of(model_registry).add( + ConditionalResources(model_registry_provided_condition) + ) # create sagemaker role - role = iam.Role(scope, id, assumed_by=iam.ServicePrincipal("sagemaker.amazonaws.com")) + role = iam.Role( + scope, id, assumed_by=iam.ServicePrincipal("sagemaker.amazonaws.com") + ) # permissions to create sagemaker resources - sagemaker_policy = sagemaker_policy_statement(is_realtime_pipeline, endpoint_name, endpoint_name_provided) + sagemaker_policy = sagemaker_policy_statement( + is_realtime_pipeline, endpoint_name, endpoint_name_provided + ) # sagemaker tags permissions sagemaker_tags_policy = sagemaker_tags_policy_statement() @@ -73,17 +78,17 @@ def create_sagemaker_role( list( set( [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket_name}", - f"arn:{core.Aws.PARTITION}:s3:::{assets_bucket_name}/*", - f"arn:{core.Aws.PARTITION}:s3:::{input_bucket_name}", - f"arn:{core.Aws.PARTITION}:s3:::{input_s3_location}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_bucket_name}/*", + f"arn:{Aws.PARTITION}:s3:::{input_bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{input_s3_location}", ] ) ) ) s3_write = s3_policy_write( [ - f"arn:{core.Aws.PARTITION}:s3:::{output_s3_location}/*", + f"arn:{Aws.PARTITION}:s3:::{output_s3_location}/*", ] ) # IAM PassRole permission diff --git a/source/lib/blueprints/byom/pipeline_definitions/source_actions.py b/source/infrastructure/lib/blueprints/pipeline_definitions/source_actions.py similarity index 68% rename from source/lib/blueprints/byom/pipeline_definitions/source_actions.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/source_actions.py index c1081fc..c560432 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/source_actions.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/source_actions.py @@ -16,40 +16,6 @@ ) -def source_action(artifact_location, assets_bucket): - """ - source_action configures a codepipeline action with S3 as source - - :artifact_location: path to the artifact (model/inference data) in the S3 bucket: assets_bucket - :assets_bucket: the bucket cdk object where pipeline assets are stored - :return: codepipeline action in a form of a CDK object that can be attached to a codepipeline stage - """ - source_output = codepipeline.Artifact() - return source_output, codepipeline_actions.S3SourceAction( - action_name="S3Source", - bucket=assets_bucket, - bucket_key=artifact_location.value_as_string, - output=source_output, - ) - - -def source_action_model_monitor(template_zip_file, assets_bucket): - """ - source_action_model_monitor configures a codepipeline action with S3 as source - - :template_zip_file: path to the template zip file in : assets_bucket containg model monitor template and parameters - :assets_bucket: the bucket cdk object where pipeline assets are stored - :return: codepipeline action in a form of a CDK object that can be attached to a codepipeline stage - """ - source_output = codepipeline.Artifact() - return source_output, codepipeline_actions.S3SourceAction( - action_name="S3Source", - bucket=assets_bucket, - bucket_key=template_zip_file.value_as_string, - output=source_output, - ) - - def source_action_custom(assets_bucket, custom_container): """ source_action configures a codepipeline action with S3 as source @@ -82,4 +48,4 @@ def source_action_template(template_location, assets_bucket): bucket=assets_bucket, bucket_key=template_location.value_as_string, output=source_output, - ) \ No newline at end of file + ) diff --git a/source/lib/blueprints/byom/pipeline_definitions/templates_parameters.py b/source/infrastructure/lib/blueprints/pipeline_definitions/templates_parameters.py similarity index 63% rename from source/lib/blueprints/byom/pipeline_definitions/templates_parameters.py rename to source/infrastructure/lib/blueprints/pipeline_definitions/templates_parameters.py index cfc0f31..c243a5d 100644 --- a/source/lib/blueprints/byom/pipeline_definitions/templates_parameters.py +++ b/source/infrastructure/lib/blueprints/pipeline_definitions/templates_parameters.py @@ -10,33 +10,34 @@ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # # ##################################################################################################################### -from aws_cdk import core +from constructs import Construct +from aws_cdk import CfnParameter, CfnCondition, Fn class ParameteresFactory: @staticmethod - def create_notification_email_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_notification_email_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "NotificationEmail", type="String", description="email for pipeline outcome notifications", - allowed_pattern="^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", + allowed_pattern="^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", constraint_description="Please enter an email address with correct format (example@example.com)", min_length=5, max_length=320, ) @staticmethod - def create_git_address_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_git_address_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "CodeCommitRepoAddress", type="String", description="AWS CodeCommit repository clone URL to connect to the framework.", allowed_pattern=( - "^(((https:\/\/|ssh:\/\/)(git\-codecommit)\.[a-zA-Z0-9_.+-]+(amazonaws\.com\/)[a-zA-Z0-9-.]" - "+(\/)[a-zA-Z0-9-.]+(\/)[a-zA-Z0-9-.]+$)|^$)" + "^(((https:\\/\\/|ssh:\\/\\/)(git\\-codecommit)\\.[a-zA-Z0-9_.+-]+(amazonaws\\.com\\/)[a-zA-Z0-9-.]" + "+(\\/)[a-zA-Z0-9-.]+(\\/)[a-zA-Z0-9-.]+$)|^$)" ), min_length=0, max_length=320, @@ -47,20 +48,20 @@ def create_git_address_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_existing_bucket_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_existing_bucket_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "ExistingS3Bucket", type="String", description="Name of existing S3 bucket to be used for ML assets. S3 Bucket must be in the same region as the deployed stack, and has versioning enabled. If not provided, a new S3 bucket will be created.", - allowed_pattern="((?=^.{3,63}$)(?!^(\d+\.)+\d+$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)|^$)", + allowed_pattern="((?=^.{3,63}$)(?!^(\\d+\\.)+\\d+$)(^(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])$)|^$)", min_length=0, max_length=63, ) @staticmethod - def create_existing_ecr_repo_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_existing_ecr_repo_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "ExistingECRRepo", type="String", @@ -71,18 +72,22 @@ def create_existing_ecr_repo_parameter(scope: core.Construct) -> core.CfnParamet ) @staticmethod - def create_account_id_parameter(scope: core.Construct, id: str, account_type: str) -> core.CfnParameter: - return core.CfnParameter( + def create_account_id_parameter( + scope: Construct, id: str, account_type: str + ) -> CfnParameter: + return CfnParameter( scope, id, type="String", description=f"AWS {account_type} account number where the CF template will be deployed", - allowed_pattern="^\d{12}$", + allowed_pattern="^\\d{12}$", ) @staticmethod - def create_org_id_parameter(scope: core.Construct, id: str, account_type: str) -> core.CfnParameter: - return core.CfnParameter( + def create_org_id_parameter( + scope: Construct, id: str, account_type: str + ) -> CfnParameter: + return CfnParameter( scope, id, type="String", @@ -91,8 +96,10 @@ def create_org_id_parameter(scope: core.Construct, id: str, account_type: str) - ) @staticmethod - def create_blueprint_bucket_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_blueprint_bucket_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BlueprintBucket", type="String", @@ -101,8 +108,10 @@ def create_blueprint_bucket_name_parameter(scope: core.Construct) -> core.CfnPar ) @staticmethod - def create_data_capture_bucket_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_data_capture_bucket_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "DataCaptureBucket", type="String", @@ -111,8 +120,10 @@ def create_data_capture_bucket_name_parameter(scope: core.Construct) -> core.Cfn ) @staticmethod - def create_baseline_output_bucket_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_baseline_output_bucket_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BaselineOutputBucket", type="String", @@ -121,8 +132,10 @@ def create_baseline_output_bucket_name_parameter(scope: core.Construct) -> core. ) @staticmethod - def create_batch_input_bucket_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_batch_input_bucket_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BatchInputBucket", type="String", @@ -131,8 +144,8 @@ def create_batch_input_bucket_name_parameter(scope: core.Construct) -> core.CfnP ) @staticmethod - def create_assets_bucket_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_assets_bucket_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "AssetsBucket", type="String", @@ -141,8 +154,10 @@ def create_assets_bucket_name_parameter(scope: core.Construct) -> core.CfnParame ) @staticmethod - def create_ground_truth_bucket_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_ground_truth_bucket_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "GroundTruthBucket", type="String", @@ -151,8 +166,10 @@ def create_ground_truth_bucket_name_parameter(scope: core.Construct) -> core.Cfn ) @staticmethod - def create_custom_algorithms_ecr_repo_arn_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_custom_algorithms_ecr_repo_arn_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "CustomAlgorithmsECRRepoArn", type="String", @@ -164,21 +181,23 @@ def create_custom_algorithms_ecr_repo_arn_parameter(scope: core.Construct) -> co ) @staticmethod - def create_kms_key_arn_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_kms_key_arn_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "KmsKeyArn", type="String", description="The KMS ARN to encrypt the output of the batch transform job and instance volume (optional).", - allowed_pattern="(^arn:(aws|aws-cn|aws-us-gov):kms:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\d:\d{12}:key/.+|^$)", + allowed_pattern="(^arn:(aws|aws-cn|aws-us-gov):kms:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:key/.+|^$)", constraint_description="Please enter kmsKey ARN", min_length=0, max_length=2048, ) @staticmethod - def create_algorithm_image_uri_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_algorithm_image_uri_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "ImageUri", type="String", @@ -186,20 +205,30 @@ def create_algorithm_image_uri_parameter(scope: core.Construct) -> core.CfnParam ) @staticmethod - def create_model_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( - scope, "ModelName", type="String", description="An arbitrary name for the model.", min_length=1 + def create_model_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( + scope, + "ModelName", + type="String", + description="An arbitrary name for the model.", + min_length=1, ) @staticmethod - def create_stack_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( - scope, "StackName", type="String", description="The name to assign to the deployed CF stack.", min_length=1 + def create_stack_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( + scope, + "StackName", + type="String", + description="The name to assign to the deployed CF stack.", + min_length=1, ) @staticmethod - def create_endpoint_name_parameter(scope: core.Construct, optional=False) -> core.CfnParameter: - return core.CfnParameter( + def create_endpoint_name_parameter( + scope: Construct, optional=False + ) -> CfnParameter: + return CfnParameter( scope, "EndpointName", type="String", @@ -208,8 +237,10 @@ def create_endpoint_name_parameter(scope: core.Construct, optional=False) -> cor ) @staticmethod - def create_model_artifact_location_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_model_artifact_location_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "ModelArtifactLocation", type="String", @@ -217,19 +248,21 @@ def create_model_artifact_location_parameter(scope: core.Construct) -> core.CfnP ) @staticmethod - def create_inference_instance_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_inference_instance_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "InferenceInstance", type="String", description="Inference instance that inference requests will be running on. E.g., ml.m5.large", - allowed_pattern="^[a-zA-Z0-9_.+-]+\.[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", + allowed_pattern="^[a-zA-Z0-9_.+-]+\\.[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", min_length=7, ) @staticmethod - def create_batch_inference_data_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_batch_inference_data_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BatchInferenceData", type="String", @@ -237,8 +270,10 @@ def create_batch_inference_data_parameter(scope: core.Construct) -> core.CfnPara ) @staticmethod - def create_batch_job_output_location_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_batch_job_output_location_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BatchOutputLocation", type="String", @@ -246,8 +281,10 @@ def create_batch_job_output_location_parameter(scope: core.Construct) -> core.Cf ) @staticmethod - def create_data_capture_location_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_data_capture_location_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "DataCaptureLocation", type="String", @@ -256,8 +293,10 @@ def create_data_capture_location_parameter(scope: core.Construct) -> core.CfnPar ) @staticmethod - def create_baseline_job_output_location_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_baseline_job_output_location_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BaselineJobOutputLocation", type="String", @@ -266,8 +305,10 @@ def create_baseline_job_output_location_parameter(scope: core.Construct) -> core ) @staticmethod - def create_monitoring_output_location_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_monitoring_output_location_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "MonitoringOutputLocation", type="String", @@ -276,8 +317,10 @@ def create_monitoring_output_location_parameter(scope: core.Construct) -> core.C ) @staticmethod - def create_schedule_expression_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_schedule_expression_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "ScheduleExpression", type="String", @@ -286,8 +329,8 @@ def create_schedule_expression_parameter(scope: core.Construct) -> core.CfnParam ) @staticmethod - def create_baseline_data_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_baseline_data_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "BaselineData", type="String", @@ -295,19 +338,21 @@ def create_baseline_data_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_instance_type_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_instance_type_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "InstanceType", type="String", description="EC2 instance type that model monitoring jobs will be running on. E.g., ml.m5.large", - allowed_pattern="^[a-zA-Z0-9_.+-]+\.[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", + allowed_pattern="^[a-zA-Z0-9_.+-]+\\.[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", min_length=7, ) @staticmethod - def create_instance_volume_size_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_instance_volume_size_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "InstanceVolumeSize", type="Number", @@ -315,8 +360,10 @@ def create_instance_volume_size_parameter(scope: core.Construct) -> core.CfnPara ) @staticmethod - def create_baseline_max_runtime_seconds_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_baseline_max_runtime_seconds_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BaselineMaxRuntimeSeconds", type="String", @@ -325,9 +372,13 @@ def create_baseline_max_runtime_seconds_parameter(scope: core.Construct) -> core ) @staticmethod - def create_monitor_max_runtime_seconds_parameter(scope: core.Construct, monitoring_type: str) -> core.CfnParameter: - max_default = "1800" if monitoring_type in ["ModelQuality", "ModelBias"] else "3600" - return core.CfnParameter( + def create_monitor_max_runtime_seconds_parameter( + scope: Construct, monitoring_type: str + ) -> CfnParameter: + max_default = ( + "1800" if monitoring_type in ["ModelQuality", "ModelBias"] else "3600" + ) + return CfnParameter( scope, "MonitorMaxRuntimeSeconds", type="Number", @@ -342,8 +393,8 @@ def create_monitor_max_runtime_seconds_parameter(scope: core.Construct, monitori ) @staticmethod - def create_baseline_job_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_baseline_job_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "BaselineJobName", type="String", @@ -353,8 +404,10 @@ def create_baseline_job_name_parameter(scope: core.Construct) -> core.CfnParamet ) @staticmethod - def create_monitoring_schedule_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_monitoring_schedule_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "MonitoringScheduleName", type="String", @@ -364,18 +417,18 @@ def create_monitoring_schedule_name_parameter(scope: core.Construct) -> core.Cfn ) @staticmethod - def create_template_zip_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_template_zip_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "TemplateZipFileName", type="String", - allowed_pattern="^.*\.zip$", + allowed_pattern="^.*\\.zip$", description="The zip file's name containing the CloudFormation template and its parameters files", ) @staticmethod - def create_sns_topic_arn_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_sns_topic_arn_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "NotificationsSNSTopicArn", type="String", @@ -384,28 +437,30 @@ def create_sns_topic_arn_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_template_file_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_template_file_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "TemplateFileName", type="String", - allowed_pattern="^.*\.yaml$", + allowed_pattern="^.*\\.yaml$", description="CloudFormation template's file name", ) @staticmethod - def create_stage_params_file_name_parameter(scope: core.Construct, id: str, stage_type: str) -> core.CfnParameter: - return core.CfnParameter( + def create_stage_params_file_name_parameter( + scope: Construct, id: str, stage_type: str + ) -> CfnParameter: + return CfnParameter( scope, id, type="String", - allowed_pattern="^.*\.json$", + allowed_pattern="^.*\\.json$", description=f"parameters json file's name for the {stage_type} stage", ) @staticmethod - def create_custom_container_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_custom_container_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "CustomImage", default="", @@ -417,8 +472,8 @@ def create_custom_container_parameter(scope: core.Construct) -> core.CfnParamete ) @staticmethod - def create_ecr_repo_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_ecr_repo_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "ECRRepoName", type="String", @@ -428,14 +483,18 @@ def create_ecr_repo_name_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_image_tag_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( - scope, "ImageTag", type="String", description="Docker image tag for the custom algorithm", min_length=1 + def create_image_tag_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( + scope, + "ImageTag", + type="String", + description="Docker image tag for the custom algorithm", + min_length=1, ) @staticmethod - def create_autopilot_job_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_autopilot_job_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "JobName", type="String", @@ -446,19 +505,21 @@ def create_autopilot_job_name_parameter(scope: core.Construct) -> core.CfnParame ) @staticmethod - def create_delegated_admin_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_delegated_admin_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "DelegatedAdminAccount", type="String", allowed_values=["Yes", "No"], default="Yes", - description="Is a delegated administrator account used to deploy accross account", + description="Is a delegated administrator account used to deploy across account", ) @staticmethod - def create_detailed_error_message_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_detailed_error_message_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "AllowDetailedErrorMessage", type="String", @@ -468,8 +529,8 @@ def create_detailed_error_message_parameter(scope: core.Construct) -> core.CfnPa ) @staticmethod - def create_use_model_registry_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_use_model_registry_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "UseModelRegistry", type="String", @@ -479,8 +540,8 @@ def create_use_model_registry_parameter(scope: core.Construct) -> core.CfnParame ) @staticmethod - def create_model_registry_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_model_registry_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "CreateModelRegistry", type="String", @@ -490,8 +551,10 @@ def create_model_registry_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_model_package_group_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_model_package_group_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "ModelPackageGroupName", type="String", @@ -500,18 +563,18 @@ def create_model_package_group_name_parameter(scope: core.Construct) -> core.Cfn ) @staticmethod - def create_model_package_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_model_package_name_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "ModelPackageName", - allowed_pattern="(^arn:aws[a-z\-]*:sagemaker:[a-z0-9\-]*:[0-9]{12}:model-package/.*|^$)", + allowed_pattern="(^arn:aws[a-z\\-]*:sagemaker:[a-z0-9\\-]*:[0-9]{12}:model-package/.*|^$)", type="String", description="The model name (version arn) in SageMaker's model package name group", ) @staticmethod - def create_instance_count_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_instance_count_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "JobInstanceCount", type="Number", @@ -520,8 +583,10 @@ def create_instance_count_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_ground_truth_s3_uri_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_ground_truth_s3_uri_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "MonitorGroundTruthInput", type="String", @@ -530,23 +595,34 @@ def create_ground_truth_s3_uri_parameter(scope: core.Construct) -> core.CfnParam ) @staticmethod - def create_problem_type_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_problem_type_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "ProblemType", type="String", - allowed_values=["Regression", "BinaryClassification", "MulticlassClassification"], + allowed_values=[ + "Regression", + "BinaryClassification", + "MulticlassClassification", + ], description="Problem type. Possible values: Regression | BinaryClassification | MulticlassClassification", ) @staticmethod - def create_autopilot_problem_type_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_autopilot_problem_type_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "ProblemType", type="String", default="", - allowed_values=["", "Regression", "BinaryClassification", "MulticlassClassification"], + allowed_values=[ + "", + "Regression", + "BinaryClassification", + "MulticlassClassification", + ], description=( "Optional Problem type. Possible values: Regression | BinaryClassification | MulticlassClassification. " "If not provided, the Autopilot will infere the probelm type from the target attribute. " @@ -555,8 +631,10 @@ def create_autopilot_problem_type_parameter(scope: core.Construct) -> core.CfnPa ) @staticmethod - def create_inference_attribute_parameter(scope: core.Construct, job_type: str) -> core.CfnParameter: - return core.CfnParameter( + def create_inference_attribute_parameter( + scope: Construct, job_type: str + ) -> CfnParameter: + return CfnParameter( scope, f"{job_type}InferenceAttribute", type="String", @@ -564,8 +642,8 @@ def create_inference_attribute_parameter(scope: core.Construct, job_type: str) - ) @staticmethod - def create_job_objective_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_job_objective_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "AutopilotJobObjective", type="String", @@ -579,8 +657,10 @@ def create_job_objective_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_max_runtime_per_job_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_max_runtime_per_job_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "MaxRuntimePerJob", type="Number", @@ -591,8 +671,8 @@ def create_max_runtime_per_job_parameter(scope: core.Construct) -> core.CfnParam ) @staticmethod - def create_total_job_runtime_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_total_job_runtime_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "AutopilotTotalRuntime", type="Number", @@ -603,8 +683,8 @@ def create_total_job_runtime_parameter(scope: core.Construct) -> core.CfnParamet ) @staticmethod - def create_training_data_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_training_data_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "TrainingData", type="String", @@ -615,8 +695,8 @@ def create_training_data_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_validation_data_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_validation_data_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "ValidationData", type="String", @@ -627,8 +707,8 @@ def create_validation_data_parameter(scope: core.Construct) -> core.CfnParameter ) @staticmethod - def create_content_type_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_content_type_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "ContentType", type="String", @@ -639,8 +719,8 @@ def create_content_type_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_s3_data_type_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_s3_data_type_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "S3DataType", type="String", @@ -650,8 +730,8 @@ def create_s3_data_type_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_data_distribution_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_data_distribution_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "DataDistribution", type="String", @@ -661,8 +741,8 @@ def create_data_distribution_parameter(scope: core.Construct) -> core.CfnParamet ) @staticmethod - def create_data_input_mode_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_data_input_mode_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "DataInputMode", type="String", @@ -672,8 +752,10 @@ def create_data_input_mode_parameter(scope: core.Construct) -> core.CfnParameter ) @staticmethod - def create_data_record_wrapping_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_data_record_wrapping_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "DataRecordWrapping", type="String", @@ -683,8 +765,10 @@ def create_data_record_wrapping_parameter(scope: core.Construct) -> core.CfnPara ) @staticmethod - def create_target_attribute_name_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_target_attribute_name_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "TargetAttribute", type="String", @@ -695,8 +779,10 @@ def create_target_attribute_name_parameter(scope: core.Construct) -> core.CfnPar ) @staticmethod - def create_max_wait_time_for_spot_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_max_wait_time_for_spot_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "MaxWaitTimeForSpotInstances", type="Number", @@ -710,8 +796,10 @@ def create_max_wait_time_for_spot_parameter(scope: core.Construct) -> core.CfnPa ) @staticmethod - def create_job_output_location_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_job_output_location_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "JobOutputLocation", type="String", @@ -722,8 +810,10 @@ def create_job_output_location_parameter(scope: core.Construct) -> core.CfnParam ) @staticmethod - def create_encrypt_inner_traffic_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_encrypt_inner_traffic_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "EncryptInnerTraffic", type="String", @@ -733,8 +823,10 @@ def create_encrypt_inner_traffic_parameter(scope: core.Construct) -> core.CfnPar ) @staticmethod - def create_generate_definitions_only_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_generate_definitions_only_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "GenerateDefinitionsOnly", type="String", @@ -744,8 +836,8 @@ def create_generate_definitions_only_parameter(scope: core.Construct) -> core.Cf ) @staticmethod - def create_compression_type_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_compression_type_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "CompressionType", type="String", @@ -755,8 +847,8 @@ def create_compression_type_parameter(scope: core.Construct) -> core.CfnParamete ) @staticmethod - def create_max_candidates_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_max_candidates_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "AutopilotMaxCandidates", default=10, @@ -766,8 +858,8 @@ def create_max_candidates_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_use_spot_instances_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_use_spot_instances_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "UseSpotInstances", type="String", @@ -777,8 +869,8 @@ def create_use_spot_instances_parameter(scope: core.Construct) -> core.CfnParame ) @staticmethod - def create_hyperparameters_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_hyperparameters_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "AlgoHyperparameteres", type="String", @@ -787,8 +879,8 @@ def create_hyperparameters_parameter(scope: core.Construct) -> core.CfnParameter ) @staticmethod - def create_attribute_names_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_attribute_names_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "AttributeNames", type="String", @@ -800,8 +892,8 @@ def create_attribute_names_parameter(scope: core.Construct) -> core.CfnParameter ) @staticmethod - def create_tuner_config_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_tuner_config_parameter(scope: Construct) -> CfnParameter: + return CfnParameter( scope, "HyperparametersTunerConfig", type="String", @@ -815,8 +907,10 @@ def create_tuner_config_parameter(scope: core.Construct) -> core.CfnParameter: ) @staticmethod - def create_hyperparameters_range_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_hyperparameters_range_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "AlgoHyperparameteresRange", type="String", @@ -828,12 +922,14 @@ def create_hyperparameters_range_parameter(scope: core.Construct) -> core.CfnPar 'Example: {"min_child_weight": ["continuous",[0, 120]], "max_depth": ["integer",[1, 15]], "optimizer": ' '["categorical", ["sgd", "Adam"]])}' ), - allowed_pattern='^\{.*:\s*\[\s*("continuous"|"integer"|"categorical")\s*,\s*\[.*\]\s*\]+\s*\}$', + allowed_pattern='^\\{.*:\\s*\\[\\s*("continuous"|"integer"|"categorical")\\s*,\\s*\\[.*\\]\\s*\\]+\\s*\\}$', ) @staticmethod - def create_probability_attribute_parameter(scope: core.Construct, job_type: str) -> core.CfnParameter: - return core.CfnParameter( + def create_probability_attribute_parameter( + scope: Construct, job_type: str + ) -> CfnParameter: + return CfnParameter( scope, f"{job_type}ProbabilityAttribute", type="String", @@ -841,8 +937,10 @@ def create_probability_attribute_parameter(scope: core.Construct, job_type: str) ) @staticmethod - def create_ground_truth_attribute_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_ground_truth_attribute_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "BaselineGroundTruthAttribute", type="String", @@ -850,8 +948,10 @@ def create_ground_truth_attribute_parameter(scope: core.Construct) -> core.CfnPa ) @staticmethod - def create_probability_threshold_attribute_parameter(scope: core.Construct) -> core.CfnParameter: - return core.CfnParameter( + def create_probability_threshold_attribute_parameter( + scope: Construct, + ) -> CfnParameter: + return CfnParameter( scope, "ProbabilityThresholdAttribute", type="String", @@ -860,7 +960,7 @@ def create_probability_threshold_attribute_parameter(scope: core.Construct) -> c @staticmethod def create_model_predicted_label_config_parameter(scope): - return core.CfnParameter( + return CfnParameter( scope, "ModelPredictedLabelConfig", type="String", @@ -873,7 +973,7 @@ def create_model_predicted_label_config_parameter(scope): @staticmethod def create_bias_config_parameter(scope): - return core.CfnParameter( + return CfnParameter( scope, "BiasConfig", type="String", @@ -886,7 +986,7 @@ def create_bias_config_parameter(scope): @staticmethod def create_shap_config_parameter(scope): - return core.CfnParameter( + return CfnParameter( scope, "SHAPConfig", type="String", @@ -900,20 +1000,20 @@ def create_shap_config_parameter(scope): @staticmethod def create_model_scores_parameter(scope): - return core.CfnParameter( + return CfnParameter( scope, "ExplainabilityModelScores", type="String", description=( "A Python int/str provided as a string (e.g., using json.dumps(5)) " "Index or JSONPath location in the model output for the predicted " - "scores to be explained. This is not required if the model output is a single score." + "scores to be explained. This is not required if the model output is a single s" ), ) @staticmethod def create_features_attribute_parameter(scope): - return core.CfnParameter( + return CfnParameter( scope, "FeaturesAttribute", type="String", @@ -924,139 +1024,154 @@ def create_features_attribute_parameter(scope): class ConditionsFactory: @staticmethod def create_custom_algorithms_ecr_repo_arn_provided_condition( - scope: core.Construct, custom_algorithms_ecr_repo_arn: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, custom_algorithms_ecr_repo_arn: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "CustomECRRepoProvided", - expression=core.Fn.condition_not( - core.Fn.condition_equals(custom_algorithms_ecr_repo_arn.value_as_string, "") + expression=Fn.condition_not( + Fn.condition_equals(custom_algorithms_ecr_repo_arn.value_as_string, "") ), ) @staticmethod def create_kms_key_arn_provided_condition( - scope: core.Construct, kms_key_arn: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, kms_key_arn: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "KmsKeyProvided", - expression=core.Fn.condition_not(core.Fn.condition_equals(kms_key_arn.value_as_string, "")), + expression=Fn.condition_not( + Fn.condition_equals(kms_key_arn.value_as_string, "") + ), ) @staticmethod def create_git_address_provided_condition( - scope: core.Construct, git_address: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, git_address: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "GitAddressProvided", - expression=core.Fn.condition_not(core.Fn.condition_equals(git_address.value_as_string, "")), + expression=Fn.condition_not( + Fn.condition_equals(git_address.value_as_string, "") + ), ) @staticmethod def create_existing_bucket_provided_condition( - scope: core.Construct, existing_bucket: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, existing_bucket: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "S3BucketProvided", - expression=core.Fn.condition_not(core.Fn.condition_equals(existing_bucket.value_as_string, "")), + expression=Fn.condition_not( + Fn.condition_equals(existing_bucket.value_as_string, "") + ), ) @staticmethod def create_existing_ecr_provided_condition( - scope: core.Construct, existing_ecr_repo: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, existing_ecr_repo: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "ECRProvided", - expression=core.Fn.condition_not(core.Fn.condition_equals(existing_ecr_repo.value_as_string, "")), + expression=Fn.condition_not( + Fn.condition_equals(existing_ecr_repo.value_as_string, "") + ), ) @staticmethod - def create_new_bucket_condition(scope: core.Construct, existing_bucket: core.CfnParameter) -> core.CfnCondition: - return core.CfnCondition( + def create_new_bucket_condition( + scope: Construct, existing_bucket: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "CreateS3Bucket", - expression=core.Fn.condition_equals(existing_bucket.value_as_string, ""), + expression=Fn.condition_equals(existing_bucket.value_as_string, ""), ) @staticmethod - def create_new_ecr_repo_condition(scope: core.Construct, existing_ecr_repo: core.CfnParameter) -> core.CfnCondition: - return core.CfnCondition( + def create_new_ecr_repo_condition( + scope: Construct, existing_ecr_repo: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "CreateECRRepo", - expression=core.Fn.condition_equals(existing_ecr_repo.value_as_string, ""), + expression=Fn.condition_equals(existing_ecr_repo.value_as_string, ""), ) @staticmethod def create_delegated_admin_condition( - scope: core.Construct, delegated_admin_parameter: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, delegated_admin_parameter: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "UseDelegatedAdmin", - expression=core.Fn.condition_equals(delegated_admin_parameter.value_as_string, "Yes"), + expression=Fn.condition_equals( + delegated_admin_parameter.value_as_string, "Yes" + ), ) @staticmethod def create_model_registry_condition( - scope: core.Construct, create_model_registry: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, create_model_registry: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "CreateModelRegistryCondition", - expression=core.Fn.condition_equals(create_model_registry.value_as_string, "Yes"), + expression=Fn.condition_equals( + create_model_registry.value_as_string, "Yes" + ), ) @staticmethod def create_model_registry_provided_condition( - scope: core.Construct, model_package_name: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, model_package_name: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "ModelRegistryProvided", - expression=core.Fn.condition_not(core.Fn.condition_equals(model_package_name.value_as_string, "")), + expression=Fn.condition_not( + Fn.condition_equals(model_package_name.value_as_string, "") + ), ) @staticmethod def create_endpoint_name_provided_condition( - scope: core.Construct, endpoint_name: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, endpoint_name: CfnParameter + ) -> CfnCondition: + return CfnCondition( scope, "EndpointNameProvided", - expression=core.Fn.condition_not(core.Fn.condition_equals(endpoint_name.value_as_string, "")), + expression=Fn.condition_not( + Fn.condition_equals(endpoint_name.value_as_string, "") + ), ) @staticmethod def create_problem_type_binary_classification_attribute_provided_condition( - scope: core.Construct, problem_type: core.CfnParameter, attribute: core.CfnParameter, attribute_name: str - ) -> core.CfnCondition: - return core.CfnCondition( + scope: Construct, + problem_type: CfnParameter, + attribute: CfnParameter, + attribute_name: str, + ) -> CfnCondition: + return CfnCondition( scope, f"ProblemTypeBinaryClassification{attribute_name}Provided", - expression=core.Fn.condition_and( - core.Fn.condition_equals(problem_type.value_as_string, "BinaryClassification"), - core.Fn.condition_not(core.Fn.condition_equals(attribute.value_as_string, "")), + expression=Fn.condition_and( + Fn.condition_equals( + problem_type.value_as_string, "BinaryClassification" + ), + Fn.condition_not(Fn.condition_equals(attribute.value_as_string, "")), ), ) - @staticmethod - def create_problem_type_binary_classification_condition( - scope: core.Construct, problem_type: core.CfnParameter - ) -> core.CfnCondition: - return core.CfnCondition( - scope, - "ProblemTypeBinaryClassification", - expression=core.Fn.condition_equals(problem_type.value_as_string, "BinaryClassification"), - ) - @staticmethod def create_attribute_provided_condition(scope, logical_id, attribute): - return core.CfnCondition( + return CfnCondition( scope, logical_id, - expression=core.Fn.condition_not(core.Fn.condition_equals(attribute, "")), + expression=Fn.condition_not(Fn.condition_equals(attribute, "")), ) diff --git a/source/lib/mlops_orchestrator_stack.py b/source/infrastructure/lib/mlops_orchestrator_stack.py similarity index 86% rename from source/lib/mlops_orchestrator_stack.py rename to source/infrastructure/lib/mlops_orchestrator_stack.py index 5f2d23f..0d4fc61 100644 --- a/source/lib/mlops_orchestrator_stack.py +++ b/source/infrastructure/lib/mlops_orchestrator_stack.py @@ -11,6 +11,18 @@ # and limitations under the License. # # ##################################################################################################################### import uuid +from constructs import Construct +from aws_cdk import ( + Stack, + Aws, + Aspects, + Fn, + CustomResource, + Duration, + CfnMapping, + CfnCondition, + CfnOutput, +) from aws_cdk import ( aws_iam as iam, aws_s3 as s3, @@ -20,47 +32,53 @@ aws_codepipeline_actions as codepipeline_actions, aws_codecommit as codecommit, aws_codebuild as codebuild, + aws_kms as kms, aws_apigateway as apigw, aws_sns_subscriptions as subscriptions, aws_sns as sns, - core, ) from aws_solutions_constructs import aws_apigateway_lambda -from lib.conditional_resource import ConditionalResources -from lib.blueprints.byom.pipeline_definitions.helpers import ( +from lib.blueprints.aspects.conditional_resource import ConditionalResources +from lib.blueprints.pipeline_definitions.helpers import ( suppress_s3_access_policy, suppress_lambda_policies, suppress_sns, ) -from lib.blueprints.byom.pipeline_definitions.templates_parameters import ( +from lib.blueprints.pipeline_definitions.templates_parameters import ( ParameteresFactory as pf, ConditionsFactory as cf, ) -from lib.blueprints.byom.pipeline_definitions.deploy_actions import ( +from lib.blueprints.pipeline_definitions.deploy_actions import ( sagemaker_layer, create_solution_helper, create_uuid_custom_resource, create_send_data_custom_resource, create_copy_assets_lambda, ) -from lib.blueprints.byom.pipeline_definitions.iam_policies import ( +from lib.blueprints.pipeline_definitions.iam_policies import ( create_invoke_lambda_policy, create_orchestrator_policy, ) -from lib.blueprints.byom.pipeline_definitions.configure_multi_account import ( +from lib.blueprints.pipeline_definitions.configure_multi_account import ( configure_multi_account_parameters_permissions, ) -from lib.blueprints.byom.pipeline_definitions.sagemaker_model_registry import ( +from lib.blueprints.pipeline_definitions.sagemaker_model_registry import ( + create_sagemaker_model_registry, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from lib.blueprints.pipeline_definitions.sagemaker_model_registry import ( create_sagemaker_model_registry, ) -from lib.blueprints.byom.pipeline_definitions.cdk_context_value import ( +from lib.blueprints.pipeline_definitions.cdk_context_value import ( get_cdk_context_value, ) -class MLOpsStack(core.Stack): +class MLOpsStack(Stack): def __init__( - self, scope: core.Construct, id: str, *, multi_account=False, **kwargs + self, scope: Construct, id: str, *, multi_account=False, **kwargs ) -> None: super().__init__(scope, id, **kwargs) @@ -132,11 +150,11 @@ def __init__( client_existing_bucket = s3.Bucket.from_bucket_arn( self, "ClientExistingBucket", - f"arn:{core.Aws.PARTITION}:s3:::{existing_bucket.value_as_string.strip()}", + f"arn:{Aws.PARTITION}:s3:::{existing_bucket.value_as_string.strip()}", ) # Create the resource if existing_bucket_provided condition is True - core.Aspects.of(client_existing_bucket).add( + Aspects.of(client_existing_bucket).add( ConditionalResources(existing_bucket_provided) ) @@ -146,9 +164,7 @@ def __init__( self, "ClientExistingECRReo", existing_ecr_repo.value_as_string ) # Create the resource if existing_ecr_provided condition is True - core.Aspects.of(client_erc_repo).add( - ConditionalResources(existing_ecr_provided) - ) + Aspects.of(client_erc_repo).add(ConditionalResources(existing_ecr_provided)) # Creating assets bucket so that users can upload ML Models to it. assets_bucket = s3.Bucket( @@ -163,10 +179,10 @@ def __init__( ) # Create the resource if create_new_bucket condition is True - core.Aspects.of(assets_bucket).add(ConditionalResources(create_new_bucket)) + Aspects.of(assets_bucket).add(ConditionalResources(create_new_bucket)) # Get assets S3 bucket's name/arn, based on the condition - assets_s3_bucket_name = core.Fn.condition_if( + assets_s3_bucket_name = Fn.condition_if( existing_bucket_provided.logical_id, client_existing_bucket.bucket_name, assets_bucket.bucket_name, @@ -184,7 +200,7 @@ def __init__( enforce_ssl=True, ) - # add S3 Server Access Logs Policy + # add override for access logs bucket access_logs_bucket.add_to_resource_policy( iam.PolicyStatement( effect=iam.Effect.ALLOW, @@ -196,11 +212,11 @@ def __init__( conditions={ "ArnLike": { "aws:SourceArn": [ - f"arn:{core.Aws.PARTITION}:s3:::{assets_s3_bucket_name}", + f"arn:{Aws.PARTITION}:s3:::{assets_s3_bucket_name}", blueprint_repository_bucket.bucket_arn, ] }, - "StringEquals": {"aws:SourceAccount": core.Aws.ACCOUNT_ID}, + "StringEquals": {"aws:SourceAccount": Aws.ACCOUNT_ID}, }, ) ) @@ -209,30 +225,33 @@ def __init__( ecr_repo = ecr.Repository(self, "ECRRepo", image_scan_on_push=True) # Create the resource if create_new_ecr condition is True - core.Aspects.of(ecr_repo).add(ConditionalResources(create_new_ecr_repo)) + Aspects.of(ecr_repo).add(ConditionalResources(create_new_ecr_repo)) # Get ECR repo's name based on the condition - ecr_repo_name = core.Fn.condition_if( + ecr_repo_name = Fn.condition_if( existing_ecr_provided.logical_id, client_erc_repo.repository_name, ecr_repo.repository_name, ).to_string() # Get ECR repo's arn based on the condition - ecr_repo_arn = core.Fn.condition_if( + ecr_repo_arn = Fn.condition_if( existing_ecr_provided.logical_id, client_erc_repo.repository_arn, ecr_repo.repository_arn, ).to_string() # create sns topic and subscription - mlops_notifications_topic = sns.Topic( - self, - "MLOpsNotificationsTopic", + mlops_notifications_topic = ( + sns.Topic( # NOSONAR: the sns topic does not contain sensitive data + self, + "MLOpsNotificationsTopic", + ) ) mlops_notifications_topic.node.default_child.cfn_options.metadata = ( suppress_sns() ) + mlops_notifications_topic.add_subscription( subscriptions.EmailSubscription( email_address=notification_email.value_as_string @@ -260,8 +279,8 @@ def __init__( # creating SageMaker Model registry # use the first 8 characters as a unique_id to be appended to the model_package_group_name - unique_id = core.Fn.select( - 0, core.Fn.split("-", create_id_function.get_att_string("UUID")) + unique_id = Fn.select( + 0, Fn.split("-", create_id_function.get_att_string("UUID")) ) model_package_group_name = f"mlops-model-registry-{unique_id}" model_registry = create_sagemaker_model_registry( @@ -281,7 +300,7 @@ def __init__( # grant permission to upload file to the blueprints bucket blueprint_repository_bucket.grant_write(custom_resource_lambda_fn) - custom_resource = core.CustomResource( + custom_resource = CustomResource( self, "CustomResourceCopyAssets", service_token=custom_resource_lambda_fn.function_arn, @@ -308,6 +327,7 @@ def __init__( lambda_passrole_policy = iam.PolicyStatement( actions=["iam:passrole"], resources=[cloudformation_role.role_arn] ) + # create sagemaker layer sm_layer = sagemaker_layer(self, blueprint_repository_bucket) # make sure the sagemaker code is uploaded first to the blueprints bucket @@ -317,17 +337,17 @@ def __init__( self, "PipelineOrchestration", lambda_function_props={ - "runtime": lambda_.Runtime.PYTHON_3_9, + "runtime": lambda_.Runtime.PYTHON_3_10, "handler": "index.handler", - "code": lambda_.Code.from_asset("lambdas/pipeline_orchestration"), + "code": lambda_.Code.from_asset("../lambdas/pipeline_orchestration"), "layers": [sm_layer], - "timeout": core.Duration.minutes(10), + "timeout": Duration.minutes(10), }, api_gateway_props={ "defaultMethodOptions": { "authorizationType": apigw.AuthorizationType.IAM, }, - "restApiName": f"{core.Aws.STACK_NAME}-orchestrator", + "restApiName": f"{Aws.STACK_NAME}-orchestrator", "proxy": False, "dataTraceEnabled": True, }, @@ -337,14 +357,19 @@ def __init__( provisioner_apigw_lambda.lambda_function.node.default_child.cfn_options.metadata = ( suppress_lambda_policies() ) + provisioner_apigw_lambda.lambda_function.node.default_child.cfn_options.metadata = ( + suppress_lambda_policies() + ) provision_resource = provisioner_apigw_lambda.api_gateway.root.add_resource( "provisionpipeline" ) + provision_resource.add_method("POST") status_resource = provisioner_apigw_lambda.api_gateway.root.add_resource( "pipelinestatus" ) + status_resource.add_method("POST") blueprint_repository_bucket.grant_read(provisioner_apigw_lambda.lambda_function) provisioner_apigw_lambda.lambda_function.add_to_role_policy( @@ -368,17 +393,19 @@ def __init__( provisioner_apigw_lambda.lambda_function.add_environment( key="ASSETS_BUCKET", value=str(assets_s3_bucket_name) ) + provisioner_apigw_lambda.lambda_function.add_environment( key="CFN_ROLE_ARN", value=str(cloudformation_role.role_arn) ) provisioner_apigw_lambda.lambda_function.add_environment( key="PIPELINE_STACK_NAME", value=pipeline_stack_name ) + provisioner_apigw_lambda.lambda_function.add_environment( key="NOTIFICATION_EMAIL", value=notification_email.value_as_string ) provisioner_apigw_lambda.lambda_function.add_environment( - key="REGION", value=core.Aws.REGION + key="REGION", value=Aws.REGION ) provisioner_apigw_lambda.lambda_function.add_environment( key="IS_MULTI_ACCOUNT", value=str(multi_account) @@ -406,6 +433,7 @@ def __init__( provisioner_apigw_lambda.lambda_function.add_environment( key="LOG_LEVEL", value="DEBUG" ) + cfn_policy_for_lambda = orchestrator_policy.node.default_child cfn_policy_for_lambda.cfn_options.metadata = { "cfn_nag": { @@ -425,12 +453,13 @@ def __init__( # Codepipeline with Git source definitions ### source_output = codepipeline.Artifact() # processing git_address to retrieve repo name - repo_name_split = core.Fn.split("/", git_address.value_as_string) - repo_name = core.Fn.select(5, repo_name_split) + repo_name_split = Fn.split("/", git_address.value_as_string) + repo_name = Fn.select(5, repo_name_split) # getting codecommit repo cdk object using 'from_repository_name' repo = codecommit.Repository.from_repository_name( self, "AWSMLOpsFrameworkRepository", repo_name ) + codebuild_project = codebuild.PipelineProject( self, "Take config file", @@ -486,11 +515,7 @@ def __init__( [provisioner_apigw_lambda.lambda_function.function_arn] ) ) - codebuild_project.add_to_role_policy( - create_invoke_lambda_policy( - [provisioner_apigw_lambda.lambda_function.function_arn] - ) - ) + pipeline_child_nodes = codecommit_pipeline.node.find_all() pipeline_child_nodes[1].node.default_child.cfn_options.metadata = { "cfn_nag": { @@ -508,25 +533,24 @@ def __init__( } # custom resource for operational metrics### - metrics_mapping = core.CfnMapping( - self, "AnonymousData", mapping={"SendAnonymousData": {"Data": "Yes"}} + metrics_mapping = CfnMapping( + self, + "AnonymizedData", + mapping={"SendAnonymizedData": {"Data": "Yes"}}, + lazy=False, ) - metrics_condition = core.CfnCondition( + metrics_condition = CfnCondition( self, - "AnonymousDatatoAWS", - expression=core.Fn.condition_equals( - metrics_mapping.find_in_map("SendAnonymousData", "Data"), "Yes" + "AnonymizedDatatoAWS", + expression=Fn.condition_equals( + metrics_mapping.find_in_map("SendAnonymizedData", "Data"), "Yes" ), ) # If user chooses Git as pipeline provision type, create codepipeline with Git repo as source - core.Aspects.of(repo).add(ConditionalResources(git_address_provided)) - core.Aspects.of(codecommit_pipeline).add( - ConditionalResources(git_address_provided) - ) - core.Aspects.of(codebuild_project).add( - ConditionalResources(git_address_provided) - ) + Aspects.of(repo).add(ConditionalResources(git_address_provided)) + Aspects.of(codecommit_pipeline).add(ConditionalResources(git_address_provided)) + Aspects.of(codebuild_project).add(ConditionalResources(git_address_provided)) # Create Template Interface paramaters_list = [ @@ -585,23 +609,21 @@ def __init__( # if you add new metrics to the cr properties, make sure to updated the allowed keys # to send in the "_sanitize_data" function in source/lambdas/solution_helper/lambda_function.py send_data_cr_properties = { - "Resource": "AnonymousMetric", + "Resource": "AnonymizedMetric", "UUID": create_id_function.get_att_string("UUID"), - "bucketSelected": core.Fn.condition_if( + "bucketSelected": Fn.condition_if( existing_bucket_provided.logical_id, "True", "False", ).to_string(), - "gitSelected": core.Fn.condition_if( + "gitSelected": Fn.condition_if( git_address_provided.logical_id, "True", "False", ).to_string(), - "Region": core.Aws.REGION, + "Region": Aws.REGION, "IsMultiAccount": str(multi_account), - "IsDelegatedAccount": is_delegated_admin - if multi_account - else core.Aws.NO_VALUE, + "IsDelegatedAccount": is_delegated_admin if multi_account else Aws.NO_VALUE, "UseModelRegistry": use_model_registry.value_as_string, "SolutionId": get_cdk_context_value(self, "SolutionId"), "Version": get_cdk_context_value(self, "Version"), @@ -613,7 +635,7 @@ def __init__( ) # create send_data_function based on metrics_condition condition - core.Aspects.of(send_data_function).add(ConditionalResources(metrics_condition)) + Aspects.of(send_data_function).add(ConditionalResources(metrics_condition)) # create template metadata self.template_options.metadata = { @@ -628,35 +650,35 @@ def __init__( } } # Outputs # - core.CfnOutput( + CfnOutput( self, id="BlueprintsBucket", value=f"https://s3.console.aws.amazon.com/s3/buckets/{blueprint_repository_bucket.bucket_name}", description="S3 Bucket to upload MLOps Framework Blueprints", ) - core.CfnOutput( + CfnOutput( self, id="AssetsBucket", value=f"https://s3.console.aws.amazon.com/s3/buckets/{assets_s3_bucket_name}", description="S3 Bucket to upload model artifact", ) - core.CfnOutput( + CfnOutput( self, id="ECRRepoName", value=ecr_repo_name, description="Amazon ECR repository's name", ) - core.CfnOutput( + CfnOutput( self, id="ECRRepoArn", value=ecr_repo_arn, description="Amazon ECR repository's arn", ) - core.CfnOutput( + CfnOutput( self, id="ModelRegistryArn", - value=core.Fn.condition_if( + value=Fn.condition_if( model_registry_condition.logical_id, model_registry.attr_model_package_group_arn, "[No Model Package Group was created]", @@ -664,7 +686,7 @@ def __init__( description="SageMaker model package group arn", ) - core.CfnOutput( + CfnOutput( self, id="MLOpsNotificationsTopicArn", value=mlops_notifications_topic.topic_arn, diff --git a/source/infrastructure/pytest.ini b/source/infrastructure/pytest.ini new file mode 100644 index 0000000..0f6439b --- /dev/null +++ b/source/infrastructure/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +norecursedirs = lib/blueprints/lambdas source/lambdas cdk.out \ No newline at end of file diff --git a/source/infrastructure/test/__init__.py b/source/infrastructure/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/infrastructure/test/aspects/test_aspects.py b/source/infrastructure/test/aspects/test_aspects.py new file mode 100644 index 0000000..cbd79b5 --- /dev/null +++ b/source/infrastructure/test/aspects/test_aspects.py @@ -0,0 +1,170 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.mlops_orchestrator_stack import MLOpsStack +from lib.blueprints.aspects.aws_sdk_config_aspect import AwsSDKConfigAspect +from lib.blueprints.aspects.protobuf_config_aspect import ProtobufConfigAspect +from lib.blueprints.aspects.app_registry_aspect import AppRegistry +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestAspects: + """Tests for cdk aspects""" + + def setup_class(self): + """Tests setup""" + app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(app, "SolutionId") + version = get_cdk_context_value(app, "Version") + solution_name = get_cdk_context_value(app, "SolutionName") + app_registry_name = get_cdk_context_value(app, "AppRegistryName") + application_type = get_cdk_context_value(app, "ApplicationType") + + # create single account stack + single_mlops_stack = MLOpsStack( + app, + "mlops-workload-orchestrator-single-account", + description=f"({solution_id}-sa) - MLOps Workload Orchestrator (Single Account Option). Version {version}", + multi_account=False, + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # add app registry to single account stack + + cdk.Aspects.of(single_mlops_stack).add( + AppRegistry( + single_mlops_stack, + "AppRegistrySingleAccount", + solution_id=solution_id, + solution_name=solution_name, + solution_version=version, + app_registry_name=app_registry_name, + application_type=application_type, + ) + ) + + # add AWS_SDK_USER_AGENT env variable to Lambda functions + cdk.Aspects.of(single_mlops_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentSingle", solution_id, version) + ) + + # add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes + cdk.Aspects.of(single_mlops_stack).add( + ProtobufConfigAspect(app, "ProtobufConfigSingle") + ) + + # create template + self.template = Template.from_stack(single_mlops_stack) + + def test_app_registry_aspect(self): + self.template.has_resource_properties( + "AWS::ServiceCatalogAppRegistry::Application", + { + "Name": { + "Fn::Join": [ + "-", + ["App", {"Ref": "AWS::StackName"}, "mlops"], + ] + }, + "Description": "Service Catalog application to track and manage all your resources for the solution %%SOLUTION_NAME%%", + "Tags": { + "Solutions:ApplicationType": "AWS-Solutions", + "Solutions:SolutionID": "SO0136", + "Solutions:SolutionName": "%%SOLUTION_NAME%%", + "Solutions:SolutionVersion": "%%VERSION%%", + }, + }, + ) + + self.template.has_resource_properties( + "AWS::ServiceCatalogAppRegistry::ResourceAssociation", + { + "Application": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "AppRegistrySingleAccountRegistrySetup*" + ), + "Id", + ] + }, + "Resource": {"Ref": "AWS::StackId"}, + "ResourceType": "CFN_STACK", + }, + ) + + self.template.has_resource_properties( + "AWS::ServiceCatalogAppRegistry::AttributeGroupAssociation", + { + "Application": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "AppRegistrySingleAccountRegistrySetup*" + ), + "Id", + ] + }, + "AttributeGroup": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "AppRegistrySingleAccountAppAttributes*" + ), + "Id", + ] + }, + }, + ) + + self.template.has_resource_properties( + "AWS::ServiceCatalogAppRegistry::AttributeGroup", + { + "Attributes": { + "applicationType": "AWS-Solutions", + "version": "%%VERSION%%", + "solutionID": "SO0136", + "solutionName": "%%SOLUTION_NAME%%", + }, + "Name": {"Fn::Join": ["", ["AttrGrp-", {"Ref": "AWS::StackName"}]]}, + "Description": "Attributes for Solutions Metadata", + }, + ) + + def test_aws_sdk_config_aspect(self): + self.template.all_resources_properties( + "AWS::Lambda::Function", + { + "Environment": { + "Variables": { + "AWS_SDK_USER_AGENT": '{"user_agent_extra": "AwsSolution/SO0136/%%VERSION%%"}', + } + } + }, + ) + + def test_aws_protocol_buffers_aspect(self): + self.template.all_resources_properties( + "AWS::Lambda::Function", + { + "Environment": { + "Variables": { + "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION": "python", + } + } + }, + ) diff --git a/source/infrastructure/test/context_helper.py b/source/infrastructure/test/context_helper.py new file mode 100644 index 0000000..1abd6c2 --- /dev/null +++ b/source/infrastructure/test/context_helper.py @@ -0,0 +1,19 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import json + + +def get_cdk_context(file): + with open(file) as json_file: + raw_context = json.load(json_file) + return raw_context diff --git a/source/infrastructure/test/ml_pipelines/test_autopilot_training_pipeline.py b/source/infrastructure/test/ml_pipelines/test_autopilot_training_pipeline.py new file mode 100644 index 0000000..d257cb9 --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_autopilot_training_pipeline.py @@ -0,0 +1,739 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.ml_pipelines.autopilot_training_pipeline import ( + AutopilotJobStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestAutoPilotTraining: + """Tests for autopilot_training_pipeline stack""" + + def setup_class(self): + """Tests setup""" + app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(app, "SolutionId") + version = get_cdk_context_value(app, "Version") + + autopilot_stack = AutopilotJobStack( + app, + "AutopilotJobStack", + description=( + f"({solution_id}-autopilot) - Autopilot training pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create template + self.template = Template.from_stack(autopilot_stack) + + def test_template_parameters(self): + """Tests for templates parameters""" + self.template.has_parameter( + "BlueprintBucket", + { + "Type": "String", + "Description": "Bucket name for blueprints of different types of ML Pipelines.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "JobName", + { + "Type": "String", + "AllowedPattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}", + "Description": "Unique name of the training job", + "MaxLength": 63, + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "ProblemType", + { + "Type": "String", + "Default": "", + "AllowedValues": [ + "", + "Regression", + "BinaryClassification", + "MulticlassClassification", + ], + "Description": "Optional Problem type. Possible values: Regression | BinaryClassification | MulticlassClassification. If not provided, the Autopilot will infere the probelm type from the target attribute. Note: if ProblemType is provided, the AutopilotJobObjective must be provided too.", + }, + ) + + self.template.has_parameter( + "AutopilotJobObjective", + { + "Type": "String", + "Default": "", + "AllowedValues": ["", "Accuracy", "MSE", "F1", "F1macro", "AUC"], + "Description": "Optional metric to optimize. If not provided, F1: used or binary classification, Accuracy: used for multiclass classification, and MSE: used for regression. Note: if AutopilotJobObjective is provided, the ProblemType must be provided too.", + }, + ) + + self.template.has_parameter( + "TrainingData", + { + "Type": "String", + "AllowedPattern": ".*", + "Description": "Training data key (located in the Assets bucket)", + "MaxLength": 128, + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "TargetAttribute", + { + "Type": "String", + "AllowedPattern": ".*", + "Description": "Target attribute name in the training data", + "MaxLength": 128, + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "JobOutputLocation", + { + "Type": "String", + "AllowedPattern": ".*", + "Description": "S3 output prefix (located in the Assets bucket)", + "MaxLength": 128, + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "CompressionType", + { + "Type": "String", + "Default": "", + "AllowedValues": ["", "Gzip"], + "Description": "Optional compression type for the training data", + }, + ) + + self.template.has_parameter( + "AutopilotMaxCandidates", + { + "Type": "Number", + "Default": 10, + "Description": "Max number of candidates to be tried by teh autopilot job", + "MinValue": 1, + }, + ) + + self.template.has_parameter( + "EncryptInnerTraffic", + { + "Type": "String", + "Default": "True", + "AllowedValues": ["True", "False"], + "Description": "Encrypt inner-container traffic for the job", + }, + ) + + self.template.has_parameter( + "MaxRuntimePerJob", + { + "Type": "Number", + "Default": 86400, + "Description": "Max runtime (in seconds) allowed per training job ", + "MaxValue": 259200, + "MinValue": 600, + }, + ) + + self.template.has_parameter( + "AutopilotTotalRuntime", + { + "Type": "Number", + "Default": 2592000, + "Description": "Autopilot total runtime (in seconds) allowed for the job", + "MaxValue": 2592000, + "MinValue": 3600, + }, + ) + + self.template.has_parameter( + "GenerateDefinitionsOnly", + { + "Type": "String", + "Default": "False", + "AllowedValues": ["True", "False"], + "Description": "generate candidate definitions only by the autopilot job", + }, + ) + + self.template.has_parameter( + "KmsKeyArn", + { + "Type": "String", + "AllowedPattern": "(^arn:(aws|aws-cn|aws-us-gov):kms:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:key/.+|^$)", + "ConstraintDescription": "Please enter kmsKey ARN", + "Description": "The KMS ARN to encrypt the output of the batch transform job and instance volume (optional).", + "MaxLength": 2048, + "MinLength": 0, + }, + ) + + self.template.has_parameter( + "NotificationsSNSTopicArn", + { + "Type": "String", + "AllowedPattern": "^arn:\\S+:sns:\\S+:\\d{12}:\\S+$", + "Description": "AWS SNS Topics arn used by the MLOps Workload Orchestrator to notify the administrator.", + }, + ) + + def test_template_conditions(self): + """Tests for templates conditions""" + self.template.has_condition( + "ProblemTypeProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "ProblemType"}, ""]}]}, + ) + + self.template.has_condition( + "JobObjectiveProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "AutopilotJobObjective"}, ""]}]}, + ) + + self.template.has_condition( + "CompressionTypeProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "CompressionType"}, ""]}]}, + ) + + self.template.has_condition( + "KMSProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "KmsKeyArn"}, ""]}]}, + ) + + def test_sagemaker_layer(self): + """Test for Lambda SageMaker layer""" + self.template.has_resource_properties( + "AWS::Lambda::LayerVersion", + { + "Content": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/sagemaker_layer.zip", + }, + "CompatibleRuntimes": ["python3.9", "python3.10"], + }, + ) + + def test_kms_policy(self): + """Tests for MLOps KMS key policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:CreateGrant", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ], + "Effect": "Allow", + "Resource": { + "Fn::If": [ + "KMSProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + } + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp("AutopilotKmsPolicy*"), + "Roles": [ + {"Ref": Match.string_like_regexp("createautopilotsagemakerrole*")} + ], + }, + ) + + self.template.has_resource("AWS::IAM::Policy", {"Condition": "KMSProvided"}) + + def test_autopilot_lambda(self): + """Tests for Autopilot Lambda function""" + self.template.has_resource_properties( + "AWS::Lambda::Function", + { + "Code": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/create_sagemaker_autopilot_job.zip", + }, + "Role": { + "Fn::GetAtt": [ + Match.string_like_regexp("autopilotjoblambdarole*"), + "Arn", + ] + }, + "Environment": { + "Variables": { + "JOB_NAME": {"Ref": "JobName"}, + "ROLE_ARN": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "createautopilotsagemakerrole*" + ), + "Arn", + ] + }, + "ASSETS_BUCKET": {"Ref": "AssetsBucket"}, + "TRAINING_DATA_KEY": {"Ref": "TrainingData"}, + "JOB_OUTPUT_LOCATION": {"Ref": "JobOutputLocation"}, + "TARGET_ATTRIBUTE_NAME": {"Ref": "TargetAttribute"}, + "KMS_KEY_ARN": { + "Fn::If": [ + "KMSProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "PROBLEM_TYPE": { + "Fn::If": [ + "ProblemTypeProvided", + {"Ref": "ProblemType"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "JOB_OBJECTIVE": { + "Fn::If": [ + "JobObjectiveProvided", + {"Ref": "AutopilotJobObjective"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "COMPRESSION_TYPE": { + "Fn::If": [ + "CompressionTypeProvided", + {"Ref": "CompressionType"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "MAX_CANDIDATES": {"Ref": "AutopilotMaxCandidates"}, + "ENCRYPT_INTER_CONTAINER_TRAFFIC": { + "Ref": "EncryptInnerTraffic" + }, + "MAX_RUNTIME_PER_JOB": {"Ref": "MaxRuntimePerJob"}, + "TOTAL_JOB_RUNTIME": {"Ref": "AutopilotTotalRuntime"}, + "GENERATE_CANDIDATE_DEFINITIONS_ONLY": { + "Ref": "GenerateDefinitionsOnly" + }, + "LOG_LEVEL": "INFO", + } + }, + "Handler": "main.handler", + "Layers": [{"Ref": Match.string_like_regexp("sagemakerlayer*")}], + "Runtime": "python3.10", + "Timeout": 600, + }, + ) + + self.template.has_resource( + "AWS::Lambda::Function", + { + "DependsOn": [ + Match.string_like_regexp("autopilotjoblambdaroleDefaultPolicy*"), + Match.string_like_regexp("autopilotjoblambdarole*"), + ], + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W89", + "reason": "The lambda function does not need to be attached to a vpc.", + }, + { + "id": "W58", + "reason": "The lambda functions role already has permissions to write cloudwatch logs", + }, + { + "id": "W92", + "reason": "The lambda function does need to define ReservedConcurrentExecutions", + }, + ] + } + }, + }, + ) + + def test_invoke_lambda(self): + """Tests for Invoke Lambda function""" + self.template.has_resource_properties( + "AWS::Lambda::Function", + { + "Code": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/invoke_lambda_custom_resource.zip", + }, + "Role": { + "Fn::GetAtt": [ + Match.string_like_regexp("InvokeAutopilotLambdaServiceRole*"), + "Arn", + ] + }, + "Handler": "index.handler", + "Runtime": "python3.10", + "Timeout": 300, + }, + ) + self.template.has_resource( + "AWS::Lambda::Function", + { + "DependsOn": [ + Match.string_like_regexp( + "InvokeAutopilotLambdaServiceRoleDefaultPolicy*" + ), + Match.string_like_regexp("InvokeAutopilotLambdaServiceRole*"), + ] + }, + ) + + def test_custom_resource_invoke_lambda(self): + """Tests for Custom resource to invoke Lambda function""" + self.template.has_resource_properties( + "Custom::InvokeLambda", + { + "ServiceToken": { + "Fn::GetAtt": [ + Match.string_like_regexp("InvokeAutopilotLambda*"), + "Arn", + ] + }, + "function_name": {"Ref": Match.string_like_regexp("AutopilotLambda*")}, + "message": { + "Fn::Join": [ + "", + [ + "Invoking lambda function: ", + {"Ref": Match.string_like_regexp("AutopilotLambda*")}, + ], + ] + }, + "Resource": "InvokeLambda", + "assets_bucket": {"Ref": "AssetsBucket"}, + "kms_key_arn": {"Ref": "KmsKeyArn"}, + "job_name": {"Ref": "JobName"}, + "training_data": {"Ref": "TrainingData"}, + "target_attribute_name": {"Ref": "TargetAttribute"}, + "job_output_location": {"Ref": "JobOutputLocation"}, + "problem_type": {"Ref": "ProblemType"}, + "objective_type": {"Ref": "AutopilotJobObjective"}, + "compression_type": {"Ref": "CompressionType"}, + "max_candidates": {"Ref": "AutopilotMaxCandidates"}, + "total_runtime": {"Ref": "AutopilotTotalRuntime"}, + "generate_candidate_definitions_only": { + "Ref": "GenerateDefinitionsOnly" + }, + }, + ) + + self.template.has_resource( + "Custom::InvokeLambda", + { + "DependsOn": [Match.string_like_regexp("AutopilotLambda*")], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + }, + ) + + def test_events_rule(self): + """Tests for Events Rule""" + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "EventBridge rule to notify the admin on the status change of the hyperparameter job used by the autopilot job", + "EventPattern": { + "detail": { + "HyperParameterTuningJobName": [{"prefix": {"Ref": "JobName"}}], + "HyperParameterTuningJobStatus": [ + "Completed", + "Failed", + "Stopped", + ], + }, + "detail-type": ["SageMaker HyperParameter Tuning Job State Change"], + "source": ["aws.sagemaker"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-HyperParameterTuningJobName": "$.detail.HyperParameterTuningJobName", + "detail-HyperParameterTuningJobStatus": "$.detail.HyperParameterTuningJobStatus", + }, + "InputTemplate": { + "Fn::Join": [ + "", + [ + '"The hyperparameter training job (used by the Autopilot job: ', + {"Ref": "JobName"}, + ') status is: ."', + ], + ] + }, + }, + } + ], + }, + ) + + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "EventBridge rule to notify the admin on the status change of the last two processing jobs used the autopilot job", + "EventPattern": { + "detail": { + "ProcessingJobName": [ + { + "prefix": { + "Fn::Join": ["", [{"Ref": "JobName"}, "-dpp"]] + } + }, + { + "prefix": { + "Fn::Join": [ + "", + [{"Ref": "JobName"}, "-documentation"], + ] + } + }, + ], + "ProcessingJobStatus": ["Completed", "Failed", "Stopped"], + }, + "detail-type": ["SageMaker Processing Job State Change"], + "source": ["aws.sagemaker"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-ProcessingJobName": "$.detail.ProcessingJobName", + "detail-ProcessingJobStatus": "$.detail.ProcessingJobStatus", + }, + "InputTemplate": { + "Fn::Join": [ + "", + [ + '"The processing job (used by the Autopilot job: ', + {"Ref": "JobName"}, + ') status is: ."', + ], + ] + }, + }, + } + ], + }, + ) + + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "EventBridge rule to notify the admin on the status change of the intermidate processing jobs used the autopilot job", + "EventPattern": { + "detail": { + "ProcessingJobName": [ + {"prefix": {"Fn::Join": ["", [{"Ref": "JobName"}, "-dp"]]}}, + {"prefix": {"Fn::Join": ["", [{"Ref": "JobName"}, "-pr"]]}}, + ], + "ProcessingJobStatus": ["Failed", "Stopped"], + }, + "detail-type": ["SageMaker Processing Job State Change"], + "source": ["aws.sagemaker"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-ProcessingJobName": "$.detail.ProcessingJobName", + "detail-ProcessingJobStatus": "$.detail.ProcessingJobStatus", + }, + "InputTemplate": { + "Fn::Join": [ + "", + [ + '"The processing job (used by the Autopilot job: ', + {"Ref": "JobName"}, + ') status is: ."', + ], + ] + }, + }, + } + ], + }, + ) + + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "EventBridge rule to notify the admin on the status change of the intermidate training jobs used the autopilot job", + "EventPattern": { + "detail": { + "TrainingJobName": [ + {"prefix": {"Fn::Join": ["", [{"Ref": "JobName"}, "-dpp"]]}} + ], + "TrainingJobStatus": ["Failed", "Stopped"], + }, + "detail-type": ["SageMaker Training Job State Change"], + "source": ["aws.sagemaker"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-TrainingJobName": "$.detail.TrainingJobName", + "detail-TrainingJobStatus": "$.detail.TrainingJobStatus", + }, + "InputTemplate": { + "Fn::Join": [ + "", + [ + '"The training job (used by the Autopilot job: ', + {"Ref": "JobName"}, + ') status is: ."', + ], + ] + }, + }, + } + ], + }, + ) + + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "EventBridge rule to notify the admin on the status change of the intermidate transform jobs used the autopilot job", + "EventPattern": { + "detail": { + "TransformJobName": [ + {"prefix": {"Fn::Join": ["", [{"Ref": "JobName"}, "-dpp"]]}} + ], + "TransformJobStatus": ["Failed", "Stopped"], + }, + "detail-type": ["SageMaker Transform Job State Change"], + "source": ["aws.sagemaker"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-TransformJobName": "$.detail.TransformJobName", + "detail-TransformJobStatus": "$.detail.TransformJobStatus", + }, + "InputTemplate": { + "Fn::Join": [ + "", + [ + '"The transform job (used by the Autopilot job: ', + {"Ref": "JobName"}, + ') status is: ."', + ], + ] + }, + }, + } + ], + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + self.template.has_output( + "AutopilotJobName", + { + "Description": "The autopilot training job's name", + "Value": {"Ref": "JobName"}, + }, + ) + + self.template.has_output( + "AutopilotJobOutputLocation", + { + "Description": "Output location of the autopilot training job", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "JobOutputLocation"}, + "/", + ], + ] + }, + }, + ) + + self.template.has_output( + "TrainingDataLocation", + { + "Description": "Training data used by the autopilot training job", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "TrainingData"}, + ], + ] + }, + }, + ) diff --git a/source/infrastructure/test/ml_pipelines/test_byom_batch_pipeline.py b/source/infrastructure/test/ml_pipelines/test_byom_batch_pipeline.py new file mode 100644 index 0000000..a99d030 --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_byom_batch_pipeline.py @@ -0,0 +1,570 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.ml_pipelines.byom_batch_pipeline import ( + BYOMBatchStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestBatchInference: + """Tests for byom_batch_pipeline stack""" + + def setup_class(self): + """Tests setup""" + self.app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(self.app, "SolutionId") + version = get_cdk_context_value(self.app, "Version") + + batch_stack = BYOMBatchStack( + self.app, + "BYOMBatchStack", + description=( + f"({solution_id}byom-bt) - BYOM Batch Transform pipeline in MLOps Workload Orchestrator. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create template + self.template = Template.from_stack(batch_stack) + + def test_template_parameters(self): + """Tests for templates parameters""" + self.template.has_parameter( + "BlueprintBucket", + { + "Type": "String", + "Description": "Bucket name for blueprints of different types of ML Pipelines.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "CustomAlgorithmsECRRepoArn", + { + "Type": "String", + "AllowedPattern": "(^arn:(aws|aws-cn|aws-us-gov):ecr:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:repository/.+|^$)", + "ConstraintDescription": "Please enter valid ECR repo ARN", + "Description": "The arn of the Amazon ECR repository where custom algorithm image is stored (optional)", + "MaxLength": 2048, + "MinLength": 0, + }, + ) + + self.template.has_parameter( + "KmsKeyArn", + { + "Type": "String", + "AllowedPattern": "(^arn:(aws|aws-cn|aws-us-gov):kms:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:key/.+|^$)", + "ConstraintDescription": "Please enter kmsKey ARN", + "Description": "The KMS ARN to encrypt the output of the batch transform job and instance volume (optional).", + "MaxLength": 2048, + "MinLength": 0, + }, + ) + + self.template.has_parameter( + "ImageUri", + { + "Type": "String", + "Description": "The algorithm image uri (build-in or custom)", + }, + ) + + self.template.has_parameter( + "ModelName", + { + "Type": "String", + "Description": "An arbitrary name for the model.", + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "ModelArtifactLocation", + { + "Type": "String", + "Description": "Path to model artifact inside assets bucket.", + }, + ) + + self.template.has_parameter( + "InferenceInstance", + { + "Type": "String", + "AllowedPattern": "^[a-zA-Z0-9_.+-]+\\.[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", + "Description": "Inference instance that inference requests will be running on. E.g., ml.m5.large", + "MinLength": 7, + }, + ) + + self.template.has_parameter( + "BatchInputBucket", + { + "Type": "String", + "Description": "Bucket name where the data input of the bact transform is stored.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "BatchInferenceData", + { + "Type": "String", + "Description": "S3 bucket path (including bucket name) to batch inference data file.", + }, + ) + + self.template.has_parameter( + "BatchOutputLocation", + { + "Type": "String", + "Description": "S3 path (including bucket name) to store the results of the batch job.", + }, + ) + + self.template.has_parameter( + "ModelPackageGroupName", + { + "Type": "String", + "Description": "SageMaker model package group name", + "MinLength": 0, + }, + ) + + self.template.has_parameter( + "ModelPackageName", + { + "Type": "String", + "AllowedPattern": "(^arn:aws[a-z\\-]*:sagemaker:[a-z0-9\\-]*:[0-9]{12}:model-package/.*|^$)", + "Description": "The model name (version arn) in SageMaker's model package name group", + }, + ) + + def test_template_conditions(self): + """Tests for templates conditions""" + self.template.has_condition( + "CustomECRRepoProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "CustomAlgorithmsECRRepoArn"}, ""]}]}, + ) + + self.template.has_condition( + "KmsKeyProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "KmsKeyArn"}, ""]}]}, + ) + + self.template.has_condition( + "ModelRegistryProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "ModelPackageName"}, ""]}]}, + ) + + def test_sagemaker_layer(self): + """Test for Lambda SageMaker layer""" + self.template.has_resource_properties( + "AWS::Lambda::LayerVersion", + { + "Content": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/sagemaker_layer.zip", + }, + "CompatibleRuntimes": ["python3.9", "python3.10"], + }, + ) + + def test_ecr_policy(self): + """Test for MLOpd ECR policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:DescribeRepositories", + "ecr:DescribeImages", + "ecr:BatchGetImage", + ], + "Effect": "Allow", + "Resource": {"Ref": "CustomAlgorithmsECRRepoArn"}, + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*", + }, + ] + ), + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp("MLOpsECRPolicy*"), + "Roles": [ + {"Ref": Match.string_like_regexp("MLOpsSagemakerBatchRole*")} + ], + }, + ) + + self.template.has_resource( + "AWS::IAM::Policy", + { + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W12", + "reason": "This ECR Policy (ecr:GetAuthorizationToken) can not have a restricted resource.", + } + ] + } + }, + "Condition": "CustomECRRepoProvided", + }, + ) + + def test_kms_policy(self): + """Tests for MLOps KMS key policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:CreateGrant", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ], + "Effect": "Allow", + "Resource": {"Ref": "KmsKeyArn"}, + } + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp("MLOpsKmsPolicy*"), + "Roles": [ + {"Ref": Match.string_like_regexp("MLOpsSagemakerBatchRole*")} + ], + }, + ) + + self.template.has_resource("AWS::IAM::Policy", {"Condition": "KmsKeyProvided"}) + + def test_model_registry_policy(self): + """Tests for SageMaker Model Registry Policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": [ + "sagemaker:DescribeModelPackageGroup", + "sagemaker:DescribeModelPackage", + "sagemaker:ListModelPackages", + "sagemaker:UpdateModelPackage", + "sagemaker:CreateModel", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":model-package-group/", + {"Ref": "ModelPackageGroupName"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":model-package/", + {"Ref": "ModelPackageGroupName"}, + "/*", + ], + ] + }, + ], + } + ] + ), + "Version": "2012-10-17", + }, + }, + ) + + self.template.has_resource( + "AWS::IAM::Policy", {"Condition": "ModelRegistryProvided"} + ) + + def test_sagemaker_model(self): + """Tests SageMaker Model probs""" + self.template.has_resource_properties( + "AWS::SageMaker::Model", + { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MLOpsSagemakerBatchRole*"), + "Arn", + ] + }, + "PrimaryContainer": { + "Image": { + "Fn::If": [ + "ModelRegistryProvided", + {"Ref": "AWS::NoValue"}, + {"Ref": "ImageUri"}, + ] + }, + "ModelDataUrl": { + "Fn::If": [ + "ModelRegistryProvided", + {"Ref": "AWS::NoValue"}, + { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "ModelArtifactLocation"}, + ], + ] + }, + ] + }, + "ModelPackageName": { + "Fn::If": [ + "ModelRegistryProvided", + {"Ref": "ModelPackageName"}, + {"Ref": "AWS::NoValue"}, + ] + }, + }, + "Tags": [{"Key": "model_name", "Value": {"Ref": "ModelName"}}], + }, + ) + + self.template.has_resource( + "AWS::SageMaker::Model", + { + "DependsOn": [ + Match.string_like_regexp("MLOpsSagemakerBatchRoleDefaultPolicy*"), + Match.string_like_regexp("MLOpsSagemakerBatchRole*"), + ] + }, + ) + + def test_batch_lambda(self): + """Tests for Batch Lambda function""" + self.template.has_resource_properties( + "AWS::Lambda::Function", + { + "Code": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/batch_transform.zip", + }, + "Role": { + "Fn::GetAtt": [ + Match.string_like_regexp("batchtransformlambdarole*"), + "Arn", + ] + }, + "Environment": { + "Variables": { + "model_name": { + "Fn::GetAtt": ["MLOpsSagemakerModel", "ModelName"] + }, + "inference_instance": {"Ref": "InferenceInstance"}, + "assets_bucket": {"Ref": "AssetsBucket"}, + "batch_inference_data": {"Ref": "BatchInferenceData"}, + "batch_job_output_location": {"Ref": "BatchOutputLocation"}, + "kms_key_arn": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "LOG_LEVEL": "INFO", + } + }, + "Handler": "main.handler", + "Layers": [{"Ref": Match.string_like_regexp("sagemakerlayer*")}], + "Runtime": "python3.10", + }, + ) + + def test_invoke_lambda(self): + """Tests for Invoke Lambda function""" + self.template.has_resource_properties( + "AWS::Lambda::Function", + { + "Code": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/invoke_lambda_custom_resource.zip", + }, + "Role": { + "Fn::GetAtt": [ + Match.string_like_regexp("InvokeBatchLambdaServiceRole*"), + "Arn", + ] + }, + "Handler": "index.handler", + "Runtime": "python3.10", + "Timeout": 300, + }, + ) + + def test_custom_resource_invoke_lambda(self): + """Tests for Custom resource to invoke Lambda function""" + self.template.has_resource_properties( + "Custom::InvokeLambda", + { + "ServiceToken": { + "Fn::GetAtt": [ + Match.string_like_regexp("InvokeBatchLambda*"), + "Arn", + ] + }, + "function_name": { + "Ref": Match.string_like_regexp("BatchTranformLambda*") + }, + "message": { + "Fn::Join": [ + "", + [ + "Invoking lambda function: ", + {"Ref": Match.string_like_regexp("BatchTranformLambda*")}, + ], + ] + }, + "Resource": "InvokeLambda", + "sagemaker_model_name": { + "Fn::GetAtt": ["MLOpsSagemakerModel", "ModelName"] + }, + "model_name": {"Ref": "ModelName"}, + "inference_instance": {"Ref": "InferenceInstance"}, + "algorithm_image": {"Ref": "ImageUri"}, + "model_artifact": {"Ref": "ModelArtifactLocation"}, + "assets_bucket": {"Ref": "AssetsBucket"}, + "batch_inference_data": {"Ref": "BatchInferenceData"}, + "batch_job_output_location": {"Ref": "BatchOutputLocation"}, + "custom_algorithms_ecr_arn": {"Ref": "CustomAlgorithmsECRRepoArn"}, + "kms_key_arn": {"Ref": "KmsKeyArn"}, + }, + ) + + self.template.has_resource( + "Custom::InvokeLambda", + { + "DependsOn": [Match.string_like_regexp("BatchTranformLambda*")], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + self.template.has_output( + "SageMakerModelName", + {"Value": {"Fn::GetAtt": ["MLOpsSagemakerModel", "ModelName"]}}, + ) + + self.template.has_output( + "BatchTransformJobName", + { + "Description": "The name of the SageMaker batch transform job", + "Value": { + "Fn::Join": [ + "", + [ + {"Fn::GetAtt": ["MLOpsSagemakerModel", "ModelName"]}, + "-batch-transform-*", + ], + ] + }, + }, + ) + + self.template.has_output( + "BatchTransformOutputLocation", + { + "Description": "Output location of the batch transform. Our will be saved under the job name", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "BatchOutputLocation"}, + "/", + ], + ] + }, + }, + ) diff --git a/source/infrastructure/test/ml_pipelines/test_byom_custom_algorithm_image_builder.py b/source/infrastructure/test/ml_pipelines/test_byom_custom_algorithm_image_builder.py new file mode 100644 index 0000000..15f0dc4 --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_byom_custom_algorithm_image_builder.py @@ -0,0 +1,497 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.ml_pipelines.byom_custom_algorithm_image_builder import ( + BYOMCustomAlgorithmImageBuilderStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestImageBuilder: + """Tests for byom_custom_algorithm_image_builder stack""" + + def setup_class(self): + """Tests setup""" + app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(app, "SolutionId") + version = get_cdk_context_value(app, "Version") + + image_builder_stack = BYOMCustomAlgorithmImageBuilderStack( + app, + "BYOMCustomAlgorithmImageBuilderStack", + description=( + f"({solution_id}byom-caib) - Bring Your Own Model pipeline to build custom algorithm docker images" + f"in MLOps Workload Orchestrator. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create template + self.template = Template.from_stack(image_builder_stack) + + def test_template_parameters(self): + """Tests for templates parameters""" + self.template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "CustomImage", + { + "Type": "String", + "Default": "", + "Description": "Should point to a zip file containing dockerfile and assets for building a custom model. If empty it will be using containers from SageMaker Registry", + }, + ) + + self.template.has_parameter( + "ECRRepoName", + { + "Type": "String", + "AllowedPattern": "(?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)*[a-z0-9]+(?:[._-][a-z0-9]+)*", + "Description": "Name of the Amazon ECR repository. This repo will be used to store custom algorithms images.", + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "ImageTag", + { + "Type": "String", + "Description": "Docker image tag for the custom algorithm", + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "NotificationsSNSTopicArn", + { + "Type": "String", + "AllowedPattern": "^arn:\\S+:sns:\\S+:\\d{12}:\\S+$", + "Description": "AWS SNS Topics arn used by the MLOps Workload Orchestrator to notify the administrator.", + }, + ) + + def test_codebuild_policy(self): + """Tests for Codebuild Policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:CompleteLayerUpload", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":ecr:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":repository/", + {"Ref": "ECRRepoName"}, + ], + ] + }, + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*", + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":logs:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":log-group:/aws/codebuild/", + {"Ref": "ContainerFactory35DC485A"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":logs:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":log-group:/aws/codebuild/", + { + "Ref": Match.string_like_regexp( + "ContainerFactory*" + ) + }, + ":*", + ], + ] + }, + ], + }, + { + "Action": [ + "codebuild:CreateReportGroup", + "codebuild:CreateReport", + "codebuild:UpdateReport", + "codebuild:BatchPutTestCases", + "codebuild:BatchPutCodeCoverages", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codebuild:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":report-group/", + { + "Ref": Match.string_like_regexp( + "ContainerFactory*" + ) + }, + "-*", + ], + ] + }, + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "BYOMPipelineRealtimeBuildArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "BYOMPipelineRealtimeBuildArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp("codebuildRoleDefaultPolicy*"), + "Roles": [{"Ref": Match.string_like_regexp("codebuildRole*")}], + }, + ) + + def test_codebuild_project(self): + """Tests for Codebuild project""" + self.template.has_resource_properties( + "AWS::CodeBuild::Project", + { + "Artifacts": {"Type": "CODEPIPELINE"}, + "Environment": { + "ComputeType": "BUILD_GENERAL1_SMALL", + "EnvironmentVariables": [ + { + "Name": "AWS_DEFAULT_REGION", + "Type": "PLAINTEXT", + "Value": {"Ref": "AWS::Region"}, + }, + { + "Name": "AWS_ACCOUNT_ID", + "Type": "PLAINTEXT", + "Value": {"Ref": "AWS::AccountId"}, + }, + { + "Name": "IMAGE_REPO_NAME", + "Type": "PLAINTEXT", + "Value": {"Ref": "ECRRepoName"}, + }, + { + "Name": "IMAGE_TAG", + "Type": "PLAINTEXT", + "Value": {"Ref": "ImageTag"}, + }, + ], + "Image": "aws/codebuild/standard:6.0", + "ImagePullCredentialsType": "CODEBUILD", + "PrivilegedMode": True, + "Type": "LINUX_CONTAINER", + }, + "ServiceRole": { + "Fn::GetAtt": [Match.string_like_regexp("codebuildRole*"), "Arn"] + }, + "Source": { + "BuildSpec": '{\n "version": "0.2",\n "phases": {\n "pre_build": {\n "commands": [\n "echo Logging in to Amazon ECR...",\n "aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com",\n "find . -iname \\"serve\\" -exec chmod 777 \\"{}\\" \\\\;",\n "find . -iname \\"train\\" -exec chmod 777 \\"{}\\" \\\\;"\n ]\n },\n "build": {\n "commands": [\n "echo Build started on `date`",\n "echo Building the Docker image...",\n "docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG .",\n "docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG"\n ]\n },\n "post_build": {\n "commands": [\n "echo Build completed on `date`",\n "echo Pushing the Docker image...",\n "docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG"\n ]\n }\n }\n}', + "Type": "CODEPIPELINE", + }, + "Cache": {"Type": "NO_CACHE"}, + "EncryptionKey": "alias/aws/s3", + }, + ) + + def test_codepipeline(self): + """Tests for Codepipeline""" + self.template.has_resource_properties( + "AWS::CodePipeline::Pipeline", + { + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("BYOMPipelineRealtimeBuildRole*"), + "Arn", + ] + }, + "Stages": [ + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Source", + "Owner": "AWS", + "Provider": "S3", + "Version": "1", + }, + "Configuration": { + "S3Bucket": {"Ref": "AssetsBucket"}, + "S3ObjectKey": {"Ref": "CustomImage"}, + }, + "Name": "S3Source", + "OutputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "BYOMPipelineRealtimeBuildSourceS3SourceCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "Source", + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Build", + "Owner": "AWS", + "Provider": "CodeBuild", + "Version": "1", + }, + "Configuration": { + "ProjectName": { + "Ref": Match.string_like_regexp( + "ContainerFactory*" + ) + } + }, + "InputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "Name": "CodeBuild", + "OutputArtifacts": [ + {"Name": "Artifact_Build_CodeBuild"} + ], + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "BYOMPipelineRealtimeBuildCodeBuildCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "Build", + }, + ], + "ArtifactStore": { + "Location": { + "Ref": Match.string_like_regexp( + "BYOMPipelineRealtimeBuildArtifactsBucket*" + ) + }, + "Type": "S3", + }, + }, + ) + + self.template.has_resource( + "AWS::CodePipeline::Pipeline", + { + "DependsOn": [ + Match.string_like_regexp( + "BYOMPipelineRealtimeBuildRoleDefaultPolicy*" + ), + Match.string_like_regexp("BYOMPipelineRealtimeBuildRole*"), + ] + }, + ) + + def test_events_rule(self): + """Tests for Events Rule""" + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "Notify user of the outcome of the pipeline", + "EventPattern": { + "detail": {"state": ["SUCCEEDED", "FAILED"]}, + "source": ["aws.codepipeline"], + "resources": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codepipeline:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "BYOMPipelineRealtimeBuild*" + ) + }, + ], + ] + } + ], + "detail-type": ["CodePipeline Pipeline Execution State Change"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-pipeline": "$.detail.pipeline", + "detail-state": "$.detail.state", + }, + "InputTemplate": '"Pipeline finished executing. Pipeline execution result is "', + }, + } + ], + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + self.template.has_output( + "Pipelines", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://console.aws.amazon.com/codesuite/codepipeline/pipelines/", + { + "Ref": Match.string_like_regexp( + "BYOMPipelineRealtimeBuild*" + ) + }, + "/view?region=", + {"Ref": "AWS::Region"}, + ], + ] + } + }, + ) + + self.template.has_output( + "CustomAlgorithmImageURI", + { + "Value": { + "Fn::Join": [ + "", + [ + {"Ref": "AWS::AccountId"}, + ".dkr.ecr.", + {"Ref": "AWS::Region"}, + ".amazonaws.com/", + {"Ref": "ECRRepoName"}, + ":", + {"Ref": "ImageTag"}, + ], + ] + } + }, + ) diff --git a/source/infrastructure/test/ml_pipelines/test_model_monitor.py b/source/infrastructure/test/ml_pipelines/test_model_monitor.py new file mode 100644 index 0000000..1479b2f --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_model_monitor.py @@ -0,0 +1,1494 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.ml_pipelines.model_monitor import ( + ModelMonitorStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestModelMonitor: + """Tests for model_monitor stack""" + + def setup_class(self): + """Tests setup""" + app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(app, "SolutionId") + version = get_cdk_context_value(app, "Version") + + # data quality stack + data_quality_monitor_stack = ModelMonitorStack( + app, + "DataQualityModelMonitorStack", + monitoring_type="DataQuality", + description=( + f"({solution_id}byom-dqmm) - DataQuality Model Monitor pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + # model quality monitor + model_quality_monitor_stack = ModelMonitorStack( + app, + "ModelQualityModelMonitorStack", + monitoring_type="ModelQuality", + description=( + f"({solution_id}byom-mqmm) - ModelQuality Model Monitor pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # model bias stack + model_bias_monitor_stack = ModelMonitorStack( + app, + "ModelBiasModelMonitorStack", + monitoring_type="ModelBias", + description=( + f"({solution_id}byom-mqmb) - ModelBias Model Monitor pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # model explainability stack + model_explainability_monitor_stack = ModelMonitorStack( + app, + "ModelExplainabilityModelMonitorStack", + monitoring_type="ModelExplainability", + description=( + f"({solution_id}byom-mqme) - ModelExplainability Model Monitor pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create templates + self.data_quality_template = Template.from_stack(data_quality_monitor_stack) + self.model_quality_template = Template.from_stack(model_quality_monitor_stack) + self.model_bias_template = Template.from_stack(model_bias_monitor_stack) + self.model_explainability_template = Template.from_stack( + model_explainability_monitor_stack + ) + + # all templates + self.templates = [ + self.data_quality_template, + self.model_quality_template, + self.model_bias_template, + self.model_explainability_template, + ] + + def test_template_parameters(self): + """Tests for templates parameters""" + for template in self.templates: + template.has_parameter( + "BlueprintBucket", + { + "Type": "String", + "Description": "Bucket name for blueprints of different types of ML Pipelines.", + "MinLength": 3, + }, + ) + template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "EndpointName", + { + "Type": "String", + "Description": "The name of the AWS SageMaker's endpoint", + "MinLength": 1, + }, + ) + + template.has_parameter( + "BaselineJobOutputLocation", + { + "Type": "String", + "Description": "S3 path (including bucket name) to store the Data Baseline Job's output.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "BaselineData", + { + "Type": "String", + "Description": "Location of the Baseline data in Assets S3 Bucket.", + }, + ) + + template.has_parameter( + "InstanceType", + { + "Type": "String", + "AllowedPattern": "^[a-zA-Z0-9_.+-]+\\.[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", + "Description": "EC2 instance type that model monitoring jobs will be running on. E.g., ml.m5.large", + "MinLength": 7, + }, + ) + + template.has_parameter( + "JobInstanceCount", + { + "Type": "Number", + "Default": "1", + "Description": "Instance count used by the job. For example, 1", + }, + ) + + template.has_parameter( + "InstanceVolumeSize", + { + "Type": "Number", + "Description": "Instance volume size used by the job. E.g., 20", + }, + ) + + template.has_parameter( + "BaselineMaxRuntimeSeconds", + { + "Type": "String", + "Default": "", + "Description": "Optional Maximum runtime in seconds the baseline job is allowed to run. E.g., 3600", + }, + ) + + template.has_parameter( + "MonitorMaxRuntimeSeconds", + { + "Type": "Number", + "Default": "1800", + "Description": " Required Maximum runtime in seconds the job is allowed to run the ModelQuality baseline job. For data quality and model explainability, this can be up to 3600 seconds for an hourly schedule. For model bias and model quality hourly schedules, this can be up to 1800 seconds.", + "MaxValue": 86400, + "MinValue": 1, + }, + ) + + template.has_parameter( + "KmsKeyArn", + { + "Type": "String", + "AllowedPattern": "(^arn:(aws|aws-cn|aws-us-gov):kms:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:key/.+|^$)", + "ConstraintDescription": "Please enter kmsKey ARN", + "Description": "The KMS ARN to encrypt the output of the batch transform job and instance volume (optional).", + "MaxLength": 2048, + "MinLength": 0, + }, + ) + + template.has_parameter( + "BaselineJobName", + { + "Type": "String", + "Description": "Unique name of the data baseline job", + "MaxLength": 63, + "MinLength": 3, + }, + ) + + template.has_parameter( + "MonitoringScheduleName", + { + "Type": "String", + "Description": "Unique name of the monitoring schedule job", + "MaxLength": 63, + "MinLength": 3, + }, + ) + + template.has_parameter( + "DataCaptureBucket", + { + "Type": "String", + "Description": "Bucket name where the data captured from SageMaker endpoint will be stored.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "BaselineOutputBucket", + { + "Type": "String", + "Description": "Bucket name where the output of the baseline job will be stored.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "DataCaptureLocation", + { + "Type": "String", + "Description": "S3 path (including bucket name) to store captured data from the Sagemaker endpoint.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "MonitoringOutputLocation", + { + "Type": "String", + "Description": "S3 path (including bucket name) to store the output of the Monitoring Schedule.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "ScheduleExpression", + { + "Type": "String", + "AllowedPattern": "^cron(\\S+\\s){5}\\S+$", + "Description": "cron expression to run the monitoring schedule. E.g., cron(0 * ? * * *), cron(0 0 ? * * *), etc.", + }, + ) + + template.has_parameter( + "ImageUri", + { + "Type": "String", + "Description": "The algorithm image uri (build-in or custom)", + }, + ) + + def test_template_conditions(self): + """Tests for templates conditions""" + for template in self.templates: + template.has_condition( + "KmsKeyProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "KmsKeyArn"}, ""]}]}, + ) + + def test_sagemaker_layer(self): + """Test for Lambda SageMaker layer""" + for template in self.templates: + template.has_resource_properties( + "AWS::Lambda::LayerVersion", + { + "Content": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/sagemaker_layer.zip", + }, + "CompatibleRuntimes": ["python3.9", "python3.10"], + }, + ) + + def test_training_kms_policy(self): + """Tests for KMS policy""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:CreateGrant", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ], + "Effect": "Allow", + "Resource": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + } + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp("BaselineKmsPolicy*"), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "createbaselinesagemakerrole*" + ) + } + ], + }, + ) + + template.has_resource("AWS::IAM::Policy", {"Condition": "KmsKeyProvided"}) + + def test_create_baseline_policy(self): + """Tests for Create Baseline Job policy""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "createbaselinesagemakerroleEF4D9DF2", + "Arn", + ] + }, + }, + { + "Action": [ + "sagemaker:CreateProcessingJob", + "sagemaker:DescribeProcessingJob", + "sagemaker:StopProcessingJob", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":processing-job/", + {"Ref": "BaselineJobName"}, + ], + ] + }, + }, + { + "Action": [ + "sagemaker:AddTags", + "sagemaker:DeleteTags", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":*", + ], + ] + }, + }, + { + "Action": ["s3:GetObject", "s3:ListBucket"], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + "/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "BaselineOutputBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "BaselineOutputBucket"}, + "/*", + ], + ] + }, + ], + }, + { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "BaselineJobOutputLocation"}, + "/*", + ], + ] + }, + }, + ] + ), + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "createbaselinesagemakerroleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "createbaselinesagemakerrole*" + ) + } + ], + }, + ) + + def test_create_baseline_lambda_policy(self): + """Tests for Create baseline Lambda policy""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "createbaselinesagemakerrole*" + ), + "Arn", + ] + }, + }, + { + "Action": [ + "sagemaker:CreateProcessingJob", + "sagemaker:DescribeProcessingJob", + "sagemaker:StopProcessingJob", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":processing-job/", + {"Ref": "BaselineJobName"}, + ], + ] + }, + }, + { + "Action": [ + "sagemaker:AddTags", + "sagemaker:DeleteTags", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":*", + ], + ] + }, + }, + { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "BaselineJobOutputLocation"}, + "/*", + ], + ] + }, + }, + { + "Action": ["s3:GetObject", "s3:ListBucket"], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + "/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "BaselineOutputBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "BaselineOutputBucket"}, + "/*", + ], + ] + }, + ], + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":logs:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":log-group:/aws/lambda/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":logs:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":log-group:*:log-stream:*", + ], + ] + }, + ], + }, + { + "Action": "logs:CreateLogGroup", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":logs:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":*", + ], + ] + }, + }, + ] + ), + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "createbaselinejoblambdaroleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "createbaselinejoblambdarole*" + ) + } + ], + }, + ) + + def test_baseline_lambda(self): + """Tests for Training Lambda function""" + for template in self.templates: + template.has_resource_properties( + "AWS::Lambda::Function", + { + "Code": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/create_baseline_job.zip", + }, + "Role": { + "Fn::GetAtt": ["createbaselinejoblambdaroleA17644CE", "Arn"] + }, + "Environment": { + "Variables": { + "MONITORING_TYPE": Match.string_like_regexp( + "(DataQuality|ModelQuality|ModelBias|ModelExplainability)" + ), + "BASELINE_JOB_NAME": {"Ref": "BaselineJobName"}, + "ASSETS_BUCKET": {"Ref": "AssetsBucket"}, + "SAGEMAKER_ENDPOINT_NAME": {"Ref": "EndpointName"}, + "BASELINE_DATA_LOCATION": {"Ref": "BaselineData"}, + "BASELINE_JOB_OUTPUT_LOCATION": { + "Ref": "BaselineJobOutputLocation" + }, + "INSTANCE_TYPE": {"Ref": "InstanceType"}, + "INSTANCE_VOLUME_SIZE": {"Ref": "InstanceVolumeSize"}, + "MAX_RUNTIME_SECONDS": {"Ref": "BaselineMaxRuntimeSeconds"}, + "ROLE_ARN": { + "Fn::GetAtt": [ + "createbaselinesagemakerroleEF4D9DF2", + "Arn", + ] + }, + "KMS_KEY_ARN": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "ENDPOINT_NAME": {"Ref": "EndpointName"}, + "MODEL_PREDICTED_LABEL_CONFIG": Match.any_value(), + "BIAS_CONFIG": Match.any_value(), + "SHAP_CONFIG": Match.any_value(), + "MODEL_SCORES": Match.any_value(), + "STACK_NAME": {"Ref": "AWS::StackName"}, + "LOG_LEVEL": "INFO", + } + }, + "Handler": "main.handler", + "Layers": [{"Ref": Match.string_like_regexp("sagemakerlayer*")}], + "Runtime": "python3.10", + "Timeout": 600, + }, + ) + + template.has_resource( + "AWS::Lambda::Function", + { + "DependsOn": [ + Match.string_like_regexp( + "createbaselinejoblambdaroleDefaultPolicy*" + ), + Match.string_like_regexp("createbaselinejoblambdarole*"), + ] + }, + ) + + def test_invoke_lambda_policy(self): + """Tests for Training Lambda function""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "createdatabaselinejob*" + ), + "Arn", + ] + }, + } + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "InvokeBaselineLambdaServiceRoleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "InvokeBaselineLambdaServiceRole*" + ) + } + ], + }, + ) + + def test_custom_resource_invoke_lambda(self): + """Tests for Custom resource to invoke Lambda function""" + for template in self.templates: + template.has_resource_properties( + "Custom::InvokeLambda", + { + "ServiceToken": { + "Fn::GetAtt": [ + Match.string_like_regexp("InvokeBaselineLambda*"), + "Arn", + ] + }, + "function_name": { + "Ref": Match.string_like_regexp("createdatabaselinejob*") + }, + "message": { + "Fn::Join": [ + "", + [ + "Invoking lambda function: ", + { + "Ref": Match.string_like_regexp( + "createdatabaselinejob*" + ) + }, + ], + ] + }, + "Resource": "InvokeLambda", + "assets_bucket_name": {"Ref": "AssetsBucket"}, + "monitoring_type": Match.string_like_regexp( + "(DataQuality|ModelQuality|ModelBias|ModelExplainability)" + ), + "baseline_job_name": {"Ref": "BaselineJobName"}, + "baseline_data_location": {"Ref": "BaselineData"}, + "baseline_output_bucket": {"Ref": "BaselineOutputBucket"}, + "baseline_job_output_location": { + "Ref": "BaselineJobOutputLocation" + }, + "endpoint_name": {"Ref": "EndpointName"}, + "instance_type": {"Ref": "InstanceType"}, + "instance_volume_size": {"Ref": "InstanceVolumeSize"}, + "max_runtime_seconds": {"Ref": "BaselineMaxRuntimeSeconds"}, + "kms_key_arn": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "stack_name": {"Ref": "AWS::StackName"}, + }, + ) + + template.has_resource( + "Custom::InvokeLambda", + { + "DependsOn": [Match.string_like_regexp("createdatabaselinejob*")], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + }, + ) + + def test_data_quality_job_definition(self): + """Test Data Quality Job Definition""" + self.data_quality_template.has_resource_properties( + "AWS::SageMaker::DataQualityJobDefinition", + { + "DataQualityAppSpecification": {"ImageUri": {"Ref": "ImageUri"}}, + "DataQualityJobInput": { + "EndpointInput": { + "EndpointName": {"Ref": "EndpointName"}, + "LocalPath": "/opt/ml/processing/input/data_quality_input", + } + }, + "DataQualityJobOutputConfig": { + "KmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "MonitoringOutputs": [ + { + "S3Output": { + "LocalPath": "/opt/ml/processing/output/data_quality_output", + "S3UploadMode": "EndOfJob", + "S3Uri": { + "Fn::Join": [ + "", + ["s3://", {"Ref": "MonitoringOutputLocation"}], + ] + }, + } + } + ], + }, + "JobResources": { + "ClusterConfig": { + "InstanceCount": {"Ref": "JobInstanceCount"}, + "InstanceType": {"Ref": "InstanceType"}, + "VolumeKmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "VolumeSizeInGB": {"Ref": "InstanceVolumeSize"}, + } + }, + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + "Arn", + ] + }, + "DataQualityBaselineConfig": { + "ConstraintsResource": { + "S3Uri": { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "BaselineJobOutputLocation"}, + "/constraints.json", + ], + ] + } + }, + "StatisticsResource": { + "S3Uri": { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "BaselineJobOutputLocation"}, + "/statistics.json", + ], + ] + } + }, + }, + "StoppingCondition": { + "MaxRuntimeInSeconds": {"Ref": "MonitorMaxRuntimeSeconds"} + }, + "Tags": [{"Key": "stack-name", "Value": {"Ref": "AWS::StackName"}}], + }, + ) + + self.data_quality_template.has_resource( + "AWS::SageMaker::DataQualityJobDefinition", + { + "DependsOn": [ + "InvokeBaselineLambdaCustomResource", + Match.string_like_regexp("MLOpsSagemakerMonitorRoleDefaultPolicy*"), + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + ] + }, + ) + + def test_model_quality_job_definition(self): + """Test Model Quality Job Definition""" + self.model_quality_template.has_resource_properties( + "AWS::SageMaker::ModelQualityJobDefinition", + { + "JobResources": { + "ClusterConfig": { + "InstanceCount": {"Ref": "JobInstanceCount"}, + "InstanceType": {"Ref": "InstanceType"}, + "VolumeKmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "VolumeSizeInGB": {"Ref": "InstanceVolumeSize"}, + } + }, + "ModelQualityAppSpecification": { + "ImageUri": {"Ref": "ImageUri"}, + "ProblemType": {"Ref": "ProblemType"}, + }, + "ModelQualityJobInput": { + "EndpointInput": { + "EndpointName": {"Ref": "EndpointName"}, + "InferenceAttribute": { + "Fn::If": [ + "InferenceAttributeProvided", + {"Ref": "MonitorInferenceAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "LocalPath": "/opt/ml/processing/input/model_quality_input", + "ProbabilityAttribute": { + "Fn::If": [ + "ProblemTypeBinaryClassificationProbabilityAttributeProvided", + {"Ref": "MonitorProbabilityAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "ProbabilityThresholdAttribute": { + "Fn::If": [ + "ProblemTypeBinaryClassificationProbabilityThresholdProvided", + {"Ref": "ProbabilityThresholdAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + }, + "GroundTruthS3Input": { + "S3Uri": { + "Fn::Join": [ + "", + ["s3://", {"Ref": "MonitorGroundTruthInput"}], + ] + } + }, + }, + "ModelQualityJobOutputConfig": { + "KmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "MonitoringOutputs": [ + { + "S3Output": { + "LocalPath": "/opt/ml/processing/output/model_quality_output", + "S3UploadMode": "EndOfJob", + "S3Uri": { + "Fn::Join": [ + "", + ["s3://", {"Ref": "MonitoringOutputLocation"}], + ] + }, + } + } + ], + }, + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + "Arn", + ] + }, + "ModelQualityBaselineConfig": { + "ConstraintsResource": { + "S3Uri": { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "BaselineJobOutputLocation"}, + "/constraints.json", + ], + ] + } + } + }, + "StoppingCondition": { + "MaxRuntimeInSeconds": {"Ref": "MonitorMaxRuntimeSeconds"} + }, + "Tags": [{"Key": "stack-name", "Value": {"Ref": "AWS::StackName"}}], + }, + ) + + self.model_quality_template.has_resource( + "AWS::SageMaker::ModelQualityJobDefinition", + { + "DependsOn": [ + "InvokeBaselineLambdaCustomResource", + Match.string_like_regexp("MLOpsSagemakerMonitorRoleDefaultPolicy*"), + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + ] + }, + ) + + def test_model_bias_job_definition(self): + """Test Model Bias Job Definition""" + self.model_bias_template.has_resource_properties( + "AWS::SageMaker::ModelBiasJobDefinition", + { + "JobResources": { + "ClusterConfig": { + "InstanceCount": {"Ref": "JobInstanceCount"}, + "InstanceType": {"Ref": "InstanceType"}, + "VolumeKmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "VolumeSizeInGB": {"Ref": "InstanceVolumeSize"}, + } + }, + "ModelBiasAppSpecification": { + "ConfigUri": { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "BaselineJobOutputLocation"}, + "/monitor/analysis_config.json", + ], + ] + }, + "ImageUri": {"Ref": "ImageUri"}, + }, + "ModelBiasJobInput": { + "EndpointInput": { + "EndpointName": {"Ref": "EndpointName"}, + "FeaturesAttribute": { + "Fn::If": [ + "FeaturesAttributeProvided", + {"Ref": "FeaturesAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "InferenceAttribute": { + "Fn::If": [ + "InferenceAttributeProvided", + {"Ref": "MonitorInferenceAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "LocalPath": "/opt/ml/processing/input/model_bias_input", + "ProbabilityAttribute": { + "Fn::If": [ + "ProblemTypeBinaryClassificationProbabilityAttributeProvided", + {"Ref": "MonitorProbabilityAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "ProbabilityThresholdAttribute": { + "Fn::If": [ + "ProblemTypeBinaryClassificationProbabilityThresholdProvided", + {"Ref": "ProbabilityThresholdAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + }, + "GroundTruthS3Input": { + "S3Uri": { + "Fn::Join": [ + "", + ["s3://", {"Ref": "MonitorGroundTruthInput"}], + ] + } + }, + }, + "ModelBiasJobOutputConfig": { + "KmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "MonitoringOutputs": [ + { + "S3Output": { + "LocalPath": "/opt/ml/processing/output/model_bias_output", + "S3UploadMode": "EndOfJob", + "S3Uri": { + "Fn::Join": [ + "", + ["s3://", {"Ref": "MonitoringOutputLocation"}], + ] + }, + } + } + ], + }, + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + "Arn", + ] + }, + "ModelBiasBaselineConfig": { + "ConstraintsResource": { + "S3Uri": { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "BaselineJobOutputLocation"}, + "/analysis.json", + ], + ] + } + } + }, + "StoppingCondition": { + "MaxRuntimeInSeconds": {"Ref": "MonitorMaxRuntimeSeconds"} + }, + "Tags": [{"Key": "stack-name", "Value": {"Ref": "AWS::StackName"}}], + }, + ) + + self.model_bias_template.has_resource( + "AWS::SageMaker::ModelBiasJobDefinition", + { + "DependsOn": [ + "InvokeBaselineLambdaCustomResource", + Match.string_like_regexp("MLOpsSagemakerMonitorRoleDefaultPolicy*"), + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + ] + }, + ) + + def test_model_explainability_job_definition(self): + """Test Model Explainability Job Definition""" + self.model_explainability_template.has_resource_properties( + "AWS::SageMaker::ModelExplainabilityJobDefinition", + { + "JobResources": { + "ClusterConfig": { + "InstanceCount": {"Ref": "JobInstanceCount"}, + "InstanceType": {"Ref": "InstanceType"}, + "VolumeKmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "VolumeSizeInGB": {"Ref": "InstanceVolumeSize"}, + } + }, + "ModelExplainabilityAppSpecification": { + "ConfigUri": { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "BaselineJobOutputLocation"}, + "/monitor/analysis_config.json", + ], + ] + }, + "ImageUri": {"Ref": "ImageUri"}, + }, + "ModelExplainabilityJobInput": { + "EndpointInput": { + "EndpointName": {"Ref": "EndpointName"}, + "FeaturesAttribute": { + "Fn::If": [ + "FeaturesAttributeProvided", + {"Ref": "FeaturesAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "InferenceAttribute": { + "Fn::If": [ + "InferenceAttributeProvided", + {"Ref": "MonitorInferenceAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "LocalPath": "/opt/ml/processing/input/model_explainability_input", + "ProbabilityAttribute": { + "Fn::If": [ + "ProblemTypeBinaryClassificationProbabilityAttributeProvided", + {"Ref": "MonitorProbabilityAttribute"}, + {"Ref": "AWS::NoValue"}, + ] + }, + } + }, + "ModelExplainabilityJobOutputConfig": { + "KmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "MonitoringOutputs": [ + { + "S3Output": { + "LocalPath": "/opt/ml/processing/output/model_explainability_output", + "S3UploadMode": "EndOfJob", + "S3Uri": { + "Fn::Join": [ + "", + ["s3://", {"Ref": "MonitoringOutputLocation"}], + ] + }, + } + } + ], + }, + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + "Arn", + ] + }, + "ModelExplainabilityBaselineConfig": { + "ConstraintsResource": { + "S3Uri": { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "BaselineJobOutputLocation"}, + "/analysis.json", + ], + ] + } + } + }, + "StoppingCondition": { + "MaxRuntimeInSeconds": {"Ref": "MonitorMaxRuntimeSeconds"} + }, + "Tags": [{"Key": "stack-name", "Value": {"Ref": "AWS::StackName"}}], + }, + ) + + self.model_explainability_template.has_resource( + "AWS::SageMaker::ModelExplainabilityJobDefinition", + { + "DependsOn": [ + "InvokeBaselineLambdaCustomResource", + Match.string_like_regexp("MLOpsSagemakerMonitorRoleDefaultPolicy*"), + Match.string_like_regexp("MLOpsSagemakerMonitorRole*"), + ] + }, + ) + + def test_monitoring_schedule(self): + """Tests for SageMaker Monitor Schedule""" + for template in self.templates: + template.has_resource_properties( + "AWS::SageMaker::MonitoringSchedule", + { + "MonitoringScheduleConfig": { + "MonitoringJobDefinitionName": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "(DataQualityJobDefinition|ModelQualityJobDefinition|ModelBiasJobDefinition|ModelExplainabilityJobDefinition)" + ), + "JobDefinitionName", + ] + }, + "MonitoringType": Match.string_like_regexp( + "(DataQuality|ModelQuality|ModelBias|ModelExplainability)" + ), + "ScheduleConfig": { + "ScheduleExpression": {"Ref": "ScheduleExpression"} + }, + }, + "MonitoringScheduleName": {"Ref": "MonitoringScheduleName"}, + "Tags": [{"Key": "stack-name", "Value": {"Ref": "AWS::StackName"}}], + }, + ) + + template.has_resource( + "AWS::SageMaker::MonitoringSchedule", + { + "DependsOn": [ + Match.string_like_regexp( + "(DataQualityJobDefinition|ModelQualityJobDefinition|ModelBiasJobDefinition|ModelExplainabilityJobDefinition)" + ) + ] + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + for template in self.templates: + template.has_output( + "BaselineName", + {"Value": {"Ref": "BaselineJobName"}}, + ) + + template.has_output( + "MonitoringScheduleJobName", + {"Value": {"Ref": "MonitoringScheduleName"}}, + ) + + template.has_output( + "MonitoringScheduleType", + { + "Value": Match.string_like_regexp( + "(DataQuality|ModelQuality|ModelBias|ModelExplainability)" + ) + }, + ) + + template.has_output( + "BaselineJobOutput", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "BaselineJobOutputLocation"}, + "/", + ], + ] + } + }, + ) + + template.has_output( + "MonitoringScheduleOutput", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "MonitoringOutputLocation"}, + "/", + {"Ref": "EndpointName"}, + "/", + {"Ref": "MonitoringScheduleName"}, + "/", + ], + ] + } + }, + ) + + template.has_output( + "MonitoredSagemakerEndpoint", + {"Value": {"Ref": "EndpointName"}}, + ) + + template.has_output( + "DataCaptureS3Location", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "DataCaptureLocation"}, + "/", + {"Ref": "EndpointName"}, + "/", + ], + ] + } + }, + ) diff --git a/source/infrastructure/test/ml_pipelines/test_model_training_pipeline.py b/source/infrastructure/test/ml_pipelines/test_model_training_pipeline.py new file mode 100644 index 0000000..d56bebc --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_model_training_pipeline.py @@ -0,0 +1,889 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.ml_pipelines.model_training_pipeline import ( + TrainingJobStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestModelTraining: + """Tests for model_training_pipeline stack""" + + def setup_class(self): + """Tests setup""" + self.app_training_job = cdk.App( + context=get_cdk_context("././cdk.json")["context"] + ) + self.app_tuner_job = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(self.app_training_job, "SolutionId") + version = get_cdk_context_value(self.app_training_job, "Version") + + training_job_stack = TrainingJobStack( + self.app_training_job, + "TrainingJobStack", + training_type="TrainingJob", + description=( + f"({solution_id}-training) - Model Training pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create training job template + self.training_job_template = Template.from_stack(training_job_stack) + + tuner_job_stack = TrainingJobStack( + self.app_tuner_job, + "HyperparamaterTunningJobStack", + training_type="HyperparameterTuningJob", + description=( + f"({solution_id}-tuner) - Model Hyperparameter Tunning pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create hyper-parameters tuner job template + self.tuner_job_template = Template.from_stack(tuner_job_stack) + + # all templates + self.templates = [self.training_job_template, self.tuner_job_template] + + def test_template_parameters(self): + """Tests for templates parameters""" + for template in self.templates: + template.has_parameter( + "BlueprintBucket", + { + "Type": "String", + "Description": "Bucket name for blueprints of different types of ML Pipelines.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + template.has_parameter( + "JobName", + { + "Type": "String", + "AllowedPattern": "^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}", + "Description": "Unique name of the training job", + "MaxLength": 63, + "MinLength": 1, + }, + ) + + template.has_parameter( + "ImageUri", + { + "Type": "String", + "Description": "The algorithm image uri (build-in or custom)", + }, + ) + + template.has_parameter( + "InstanceType", + { + "Type": "String", + "AllowedPattern": "^[a-zA-Z0-9_.+-]+\\.[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", + "Description": "EC2 instance type that model monitoring jobs will be running on. E.g., ml.m5.large", + "MinLength": 7, + }, + ) + + template.has_parameter( + "JobInstanceCount", + { + "Type": "Number", + "Default": "1", + "Description": "Instance count used by the job. For example, 1", + }, + ) + + template.has_parameter( + "InstanceVolumeSize", + { + "Type": "Number", + "Description": "Instance volume size used by the job. E.g., 20", + }, + ) + + template.has_parameter( + "JobOutputLocation", + { + "Type": "String", + "AllowedPattern": ".*", + "Description": "S3 output prefix (located in the Assets bucket)", + "MaxLength": 128, + "MinLength": 1, + }, + ) + + template.has_parameter( + "KmsKeyArn", + { + "Type": "String", + "AllowedPattern": "(^arn:(aws|aws-cn|aws-us-gov):kms:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:key/.+|^$)", + "ConstraintDescription": "Please enter kmsKey ARN", + "Description": "The KMS ARN to encrypt the output of the batch transform job and instance volume (optional).", + "MaxLength": 2048, + "MinLength": 0, + }, + ) + + template.has_parameter( + "TrainingData", + { + "Type": "String", + "AllowedPattern": ".*", + "Description": "Training data key (located in the Assets bucket)", + "MaxLength": 128, + "MinLength": 1, + }, + ) + + template.has_parameter( + "ValidationData", + { + "Type": "String", + "AllowedPattern": ".*", + "Description": "Optional Validation data S3 key (located in the Assets bucket)", + "MaxLength": 128, + "MinLength": 0, + }, + ) + + template.has_parameter( + "EncryptInnerTraffic", + { + "Type": "String", + "Default": "True", + "AllowedValues": ["True", "False"], + "Description": "Encrypt inner-container traffic for the job", + }, + ) + + template.has_parameter( + "MaxRuntimePerJob", + { + "Type": "Number", + "Default": 86400, + "Description": "Max runtime (in seconds) allowed per training job ", + "MaxValue": 259200, + "MinValue": 600, + }, + ) + + template.has_parameter( + "UseSpotInstances", + { + "Type": "String", + "Default": "True", + "AllowedValues": ["True", "False"], + "Description": "Use managed spot instances with the training job.", + }, + ) + + template.has_parameter( + "MaxWaitTimeForSpotInstances", + { + "Type": "Number", + "Default": 172800, + "Description": "Max wait time (in seconds) for Spot instances (required if use_spot_instances = True). Must be greater than MaxRuntimePerJob.", + "MaxValue": 259200, + "MinValue": 1, + }, + ) + + template.has_parameter( + "ContentType", + { + "Type": "String", + "Default": "csv", + "AllowedPattern": ".*", + "Description": "The MIME type of the training data.", + "MaxLength": 256, + }, + ) + + template.has_parameter( + "S3DataType", + { + "Type": "String", + "Default": "S3Prefix", + "AllowedValues": [ + "S3Prefix", + "ManifestFile", + "AugmentedManifestFile", + ], + "Description": "Training S3 data type. S3Prefix | ManifestFile | AugmentedManifestFile.", + }, + ) + + template.has_parameter( + "DataDistribution", + { + "Type": "String", + "Default": "FullyReplicated", + "AllowedValues": ["FullyReplicated", "ShardedByS3Key"], + "Description": "Data distribution. FullyReplicated | ShardedByS3Key.", + }, + ) + + template.has_parameter( + "CompressionType", + { + "Type": "String", + "Default": "", + "AllowedValues": ["", "Gzip"], + "Description": "Optional compression type for the training data", + }, + ) + + template.has_parameter( + "DataInputMode", + { + "Type": "String", + "Default": "File", + "AllowedValues": ["File", "Pipe", "FastFile"], + "Description": "Training data input mode. File | Pipe | FastFile.", + }, + ) + + template.has_parameter( + "DataRecordWrapping", + { + "Type": "String", + "Default": "", + "AllowedValues": ["", "RecordIO"], + "Description": "Optional training data record wrapping: RecordIO. ", + }, + ) + + template.has_parameter( + "AttributeNames", + { + "Type": "String", + "AllowedPattern": "(^\\[.*\\]$|^$)", + "Description": "Optional list of one or more attribute names to use that are found in a specified AugmentedManifestFile (if S3DataType='AugmentedManifestFile')", + }, + ) + + template.has_parameter( + "AlgoHyperparameteres", + { + "Type": "String", + "AllowedPattern": "^\\{(.*:.*)+\\}$", + "Description": "Algorithm hyperparameters provided as a json object", + }, + ) + + template.has_parameter( + "NotificationsSNSTopicArn", + { + "Type": "String", + "AllowedPattern": "^arn:\\S+:sns:\\S+:\\d{12}:\\S+$", + "Description": "AWS SNS Topics arn used by the MLOps Workload Orchestrator to notify the administrator.", + }, + ) + + # CF parameters only for hyper-parameters tuner job + self.tuner_job_template.has_parameter( + "HyperparametersTunerConfig", + { + "Type": "String", + "AllowedPattern": "^\\{(.*:.*)+\\}$", + "Description": "sagemaker.tuner.HyperparameterTuner configs (objective_metric_name, metric_definitions, strategy, objective_type, max_jobs, max_parallel_jobs, base_tuning_job_name=None, early_stopping_type) provided as a json object. Note: some has default values and are not required to be specified. Example: {'early_stopping_type' = 'Auto', 'objective_metric_name' = 'validation:auc', 'max_jobs' = 10, 'max_parallel_jobs' = 2}", + }, + ) + + self.tuner_job_template.has_parameter( + "AlgoHyperparameteresRange", + { + "Type": "String", + "AllowedPattern": '^\\{.*:\\s*\\[\\s*("continuous"|"integer"|"categorical")\\s*,\\s*\\[.*\\]\\s*\\]+\\s*\\}$', + "Description": 'Algorithm hyperparameters range used by the Hyperparameters job provided as a json object, where the key is hyperparameter name, and the value is list with the first item the type (\'continuous\'|\'integer\'|\'categorical\') and the second item is a list of [min_value, max_value] for \'continuous\'|\'integer\' and a list of values for \'categorical\'. Example: {"min_child_weight": ["continuous",[0, 120]], "max_depth": ["integer",[1, 15]], "optimizer": ["categorical", ["sgd", "Adam"]])}', + }, + ) + + def test_template_conditions(self): + """Tests for templates conditions""" + for template in self.templates: + template.has_condition( + "ValidationDataProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "ValidationData"}, ""]}]}, + ) + + template.has_condition( + "RecordWrappingProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "DataRecordWrapping"}, ""]}]}, + ) + + template.has_condition( + "RecordWrappingProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "DataRecordWrapping"}, ""]}]}, + ) + + template.has_condition( + "CompressionTypeProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "CompressionType"}, ""]}]}, + ) + + template.has_condition( + "KMSProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "KmsKeyArn"}, ""]}]}, + ) + + template.has_condition( + "AttributeNamesProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "AttributeNames"}, ""]}]}, + ) + + def test_sagemaker_layer(self): + """Test for Lambda SageMaker layer""" + for template in self.templates: + template.has_resource_properties( + "AWS::Lambda::LayerVersion", + { + "Content": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/sagemaker_layer.zip", + }, + "CompatibleRuntimes": ["python3.9", "python3.10"], + }, + ) + + def test_training_job_policy(self): + """Tests for Training Job policy""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": Match.string_like_regexp( + "(sagemaker:CreateTrainingJob|sagemaker:CreateHyperParameterTuningJob)" + ), + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + Match.string_like_regexp( + "(:training-job/|:hyper-parameter-tuning-job/)" + ), + {"Ref": "JobName"}, + ], + ] + }, + }, + { + "Action": ["s3:GetObject", "s3:ListBucket"], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + "/*", + ], + ] + }, + ], + }, + { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "JobOutputLocation"}, + "/*", + ], + ] + }, + }, + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "createtrainingjobsagemakerrole*" + ), + "Arn", + ] + }, + }, + ] + ), + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "createtrainingjobsagemakerroleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "createtrainingjobsagemakerrole*" + ) + } + ], + }, + ) + + def test_training_kms_policy(self): + """Tests for KMS policy""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:CreateGrant", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ], + "Effect": "Allow", + "Resource": { + "Fn::If": [ + "KMSProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + } + ], + "Version": "2012-10-17", + }, + }, + ) + + template.has_resource("AWS::IAM::Policy", {"Condition": "KMSProvided"}) + + def test_training_lambda(self): + """Tests for Training Lambda function""" + for template in self.templates: + template.has_resource_properties( + "AWS::Lambda::Function", + { + "Code": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/create_model_training_job.zip", + }, + "Role": { + "Fn::GetAtt": [ + Match.string_like_regexp("trainingjoblambdarole*"), + "Arn", + ] + }, + "Environment": { + "Variables": { + "JOB_NAME": {"Ref": "JobName"}, + "ROLE_ARN": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "createtrainingjobsagemakerrole*" + ), + "Arn", + ] + }, + "ASSETS_BUCKET": {"Ref": "AssetsBucket"}, + "TRAINING_DATA_KEY": {"Ref": "TrainingData"}, + "VALIDATION_DATA_KEY": { + "Fn::If": [ + "ValidationDataProvided", + {"Ref": "ValidationData"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "JOB_OUTPUT_LOCATION": {"Ref": "JobOutputLocation"}, + "S3_DATA_TYPE": {"Ref": "S3DataType"}, + "DATA_INPUT_MODE": {"Ref": "DataInputMode"}, + "CONTENT_TYPE": {"Ref": "ContentType"}, + "DATA_DISTRIBUTION": {"Ref": "DataDistribution"}, + "ATTRIBUTE_NAMES": { + "Fn::If": [ + "AttributeNamesProvided", + {"Ref": "AttributeNames"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "JOB_TYPE": Match.string_like_regexp( + "(TrainingJob|HyperparameterTuningJob)" + ), + "KMS_KEY_ARN": { + "Fn::If": [ + "KMSProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "IMAGE_URI": {"Ref": "ImageUri"}, + "INSTANCE_TYPE": {"Ref": "InstanceType"}, + "INSTANCE_COUNT": {"Ref": "JobInstanceCount"}, + "COMPRESSION_TYPE": { + "Fn::If": [ + "CompressionTypeProvided", + {"Ref": "CompressionType"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "DATA_RECORD_WRAPPING": { + "Fn::If": [ + "RecordWrappingProvided", + {"Ref": "DataRecordWrapping"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "INSTANCE_VOLUME_SIZE": {"Ref": "InstanceVolumeSize"}, + "ENCRYPT_INTER_CONTAINER_TRAFFIC": { + "Ref": "EncryptInnerTraffic" + }, + "JOB_MAX_RUN_SECONDS": {"Ref": "MaxRuntimePerJob"}, + "USE_SPOT_INSTANCES": {"Ref": "UseSpotInstances"}, + "MAX_WAIT_SECONDS": {"Ref": "MaxWaitTimeForSpotInstances"}, + "HYPERPARAMETERS": {"Ref": "AlgoHyperparameteres"}, + "TUNER_CONFIG": Match.any_value(), + "HYPERPARAMETER_RANGES": Match.any_value(), + "LOG_LEVEL": "INFO", + } + }, + "Handler": "main.handler", + "Layers": [{"Ref": Match.string_like_regexp("sagemakerlayer*")}], + "Runtime": "python3.10", + "Timeout": 600, + }, + ) + + template.has_resource( + "AWS::Lambda::Function", + { + "DependsOn": [ + Match.string_like_regexp("trainingjoblambdaroleDefaultPolicy*"), + Match.string_like_regexp("trainingjoblambdarole*"), + ] + }, + ) + + def test_invoke_lambda_policy(self): + """Tests for Training Lambda function""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "ModelTrainingLambda*" + ), + "Arn", + ] + }, + } + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "InvokeTrainingLambdaServiceRoleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "InvokeTrainingLambdaServiceRole*" + ) + } + ], + }, + ) + + def test_custom_resource_invoke_lambda(self): + """Tests for Custom resource to invoke Lambda function""" + for template in self.templates: + template.has_resource_properties( + "Custom::InvokeLambda", + { + "ServiceToken": { + "Fn::GetAtt": ["InvokeTrainingLambda77BDAF93", "Arn"] + }, + "function_name": {"Ref": "ModelTrainingLambdaEB62AC60"}, + "message": { + "Fn::Join": [ + "", + [ + "Invoking lambda function: ", + {"Ref": "ModelTrainingLambdaEB62AC60"}, + ], + ] + }, + "Resource": "InvokeLambda", + "assets_bucket": {"Ref": "AssetsBucket"}, + "job_type": Match.string_like_regexp( + "(TrainingJob|HyperparameterTuningJob)" + ), + "job_name": {"Ref": "JobName"}, + "training_data": {"Ref": "TrainingData"}, + "validation_data": { + "Fn::If": [ + "ValidationDataProvided", + {"Ref": "ValidationData"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "s3_data_type": {"Ref": "S3DataType"}, + "content_type": {"Ref": "ContentType"}, + "data_distribution": {"Ref": "DataDistribution"}, + "compression_type": { + "Fn::If": [ + "CompressionTypeProvided", + {"Ref": "CompressionType"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "data_input_mode": {"Ref": "DataInputMode"}, + "data_record_wrapping": { + "Fn::If": [ + "RecordWrappingProvided", + {"Ref": "DataRecordWrapping"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "attribute_names": { + "Fn::If": [ + "AttributeNamesProvided", + {"Ref": "AttributeNames"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "hyperparameters": {"Ref": "AlgoHyperparameteres"}, + "job_output_location": {"Ref": "JobOutputLocation"}, + "image_uri": {"Ref": "ImageUri"}, + "instance_type": {"Ref": "InstanceType"}, + "instance_count": {"Ref": "JobInstanceCount"}, + "instance_volume_size": {"Ref": "InstanceVolumeSize"}, + "kms_key_arn": { + "Fn::If": [ + "KMSProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "encrypt_inter_container_traffic": {"Ref": "EncryptInnerTraffic"}, + "max_runtime_per_training_job_in_seconds": { + "Ref": "MaxRuntimePerJob" + }, + "use_spot_instances": {"Ref": "UseSpotInstances"}, + "max_wait_time_for_spot": {"Ref": "MaxWaitTimeForSpotInstances"}, + }, + ) + + template.has_resource( + "Custom::InvokeLambda", + { + "DependsOn": [Match.string_like_regexp("ModelTrainingLambda*")], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + }, + ) + + def test_events_rule(self): + """Tests for Events Rule""" + self.training_job_template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "EventBridge rule to notify the admin on the status change of the job", + "EventPattern": { + "detail": { + "TrainingJobName": [{"Ref": "JobName"}], + "TrainingJobStatus": ["Completed", "Failed", "Stopped"], + }, + "detail-type": ["SageMaker Training Job State Change"], + "source": ["aws.sagemaker"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-TrainingJobName": "$.detail.TrainingJobName", + "detail-TrainingJobStatus": "$.detail.TrainingJobStatus", + }, + "InputTemplate": '"The training job status is: ."', + }, + } + ], + }, + ) + + self.tuner_job_template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "EventBridge rule to notify the admin on the status change of the job", + "EventPattern": { + "detail": { + "HyperParameterTuningJobName": [{"Ref": "JobName"}], + "HyperParameterTuningJobStatus": [ + "Completed", + "Failed", + "Stopped", + ], + }, + "detail-type": ["SageMaker HyperParameter Tuning Job State Change"], + "source": ["aws.sagemaker"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-HyperParameterTuningJobName": "$.detail.HyperParameterTuningJobName", + "detail-HyperParameterTuningJobStatus": "$.detail.HyperParameterTuningJobStatus", + }, + "InputTemplate": '"The hyperparameter training job status is: ."', + }, + } + ], + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + for template in self.templates: + template.has_output( + "TrainingJobName", + {"Description": "The training job's name", "Value": {"Ref": "JobName"}}, + ) + + template.has_output( + "TrainingJobOutputLocation", + { + "Description": "Output location of the training job", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "JobOutputLocation"}, + "/", + ], + ] + }, + }, + ) + + template.has_output( + "TrainingDataLocation", + { + "Description": "Training data used by the training job", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "TrainingData"}, + ], + ] + }, + }, + ) + + template.has_output( + "ValidationDataLocation", + { + "Description": "Training data used by the training job", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "TrainingData"}, + ], + ] + }, + }, + ) diff --git a/source/infrastructure/test/ml_pipelines/test_multi_account_codepipeline.py b/source/infrastructure/test/ml_pipelines/test_multi_account_codepipeline.py new file mode 100644 index 0000000..f5cddf3 --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_multi_account_codepipeline.py @@ -0,0 +1,931 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.ml_pipelines.multi_account_codepipeline import ( + MultiAccountCodePipelineStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestMultiAccountCodePipelineStack: + """Tests for multi_account_codepipeline stack""" + + def setup_class(self): + """Tests setup""" + self.app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(self.app, "SolutionId") + version = get_cdk_context_value(self.app, "Version") + + multi_codepipeline = MultiAccountCodePipelineStack( + self.app, + "MultiAccountCodePipelineStack", + description=( + f"({solution_id}byom-mac) - Multi-account codepipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create template + self.template = Template.from_stack(multi_codepipeline) + + def test_template_parameters(self): + """Tests for templates parameters""" + self.template.has_parameter( + "TemplateZipFileName", + { + "Type": "String", + "AllowedPattern": "^.*\\.zip$", + "Description": "The zip file's name containing the CloudFormation template and its parameters files", + }, + ) + + self.template.has_parameter( + "TemplateFileName", + { + "Type": "String", + "AllowedPattern": "^.*\\.yaml$", + "Description": "CloudFormation template's file name", + }, + ) + + self.template.has_parameter( + "DevParamsName", + { + "Type": "String", + "AllowedPattern": "^.*\\.json$", + "Description": "parameters json file's name for the development stage", + }, + ) + + self.template.has_parameter( + "StagingParamsName", + { + "Type": "String", + "AllowedPattern": "^.*\\.json$", + "Description": "parameters json file's name for the staging stage", + }, + ) + + self.template.has_parameter( + "ProdParamsName", + { + "Type": "String", + "AllowedPattern": "^.*\\.json$", + "Description": "parameters json file's name for the production stage", + }, + ) + + self.template.has_parameter( + "NotificationsSNSTopicArn", + { + "Type": "String", + "AllowedPattern": "^arn:\\S+:sns:\\S+:\\d{12}:\\S+$", + "Description": "AWS SNS Topics arn used by the MLOps Workload Orchestrator to notify the administrator.", + }, + ) + + self.template.has_parameter( + "DevAccountId", + { + "Type": "String", + "AllowedPattern": "^\\d{12}$", + "Description": "AWS development account number where the CF template will be deployed", + }, + ) + + self.template.has_parameter( + "DevOrgId", + { + "Type": "String", + "AllowedPattern": "^ou-[0-9a-z]{4,32}-[a-z0-9]{8,32}$", + "Description": "AWS development organizational unit id where the CF template will be deployed", + }, + ) + + self.template.has_parameter( + "StagingAccountId", + { + "Type": "String", + "AllowedPattern": "^\\d{12}$", + "Description": "AWS staging account number where the CF template will be deployed", + }, + ) + + self.template.has_parameter( + "StagingOrgId", + { + "Type": "String", + "AllowedPattern": "^ou-[0-9a-z]{4,32}-[a-z0-9]{8,32}$", + "Description": "AWS staging organizational unit id where the CF template will be deployed", + }, + ) + + self.template.has_parameter( + "ProdAccountId", + { + "Type": "String", + "AllowedPattern": "^\\d{12}$", + "Description": "AWS production account number where the CF template will be deployed", + }, + ) + + self.template.has_parameter( + "ProdOrgId", + { + "Type": "String", + "AllowedPattern": "^ou-[0-9a-z]{4,32}-[a-z0-9]{8,32}$", + "Description": "AWS production organizational unit id where the CF template will be deployed", + }, + ) + + self.template.has_parameter( + "BlueprintBucket", + { + "Type": "String", + "Description": "Bucket name for blueprints of different types of ML Pipelines.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "StackName", + { + "Type": "String", + "Description": "The name to assign to the deployed CF stack.", + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "DelegatedAdminAccount", + { + "Type": "String", + "Default": "Yes", + "AllowedValues": ["Yes", "No"], + "Description": "Is a delegated administrator account used to deploy across account", + }, + ) + + def test_template_conditions(self): + """Tests for templates conditions""" + self.template.has_condition( + "UseDelegatedAdmin", + {"Fn::Equals": [{"Ref": "DelegatedAdminAccount"}, "Yes"]}, + ) + + def test_all_s3_buckets_properties(self): + """Tests for S3 buckets properties""" + self.template.resource_count_is("AWS::S3::Bucket", 1) + # assert for all bucket, encryption is enabled and Public Access is Blocked + self.template.all_resources_properties( + "AWS::S3::Bucket", + { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + {"ServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}} + ] + }, + "PublicAccessBlockConfiguration": Match.object_equals( + { + "BlockPublicAcls": True, + "BlockPublicPolicy": True, + "IgnorePublicAcls": True, + "RestrictPublicBuckets": True, + } + ), + }, + ) + + def test_all_s3_buckets_policy(self): + """Tests for S3 buckets policies""" + # we have 1 S3 bucket, so we should have 1 bucket policies + self.template.resource_count_is("AWS::S3::BucketPolicy", 1) + # assert all buckets have bucket policy to enforce SecureTransport + self.template.all_resources_properties( + "AWS::S3::BucketPolicy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": "s3:*", + "Condition": {"Bool": {"aws:SecureTransport": "false"}}, + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + } + ] + ), + "Version": "2012-10-17", + } + }, + ) + + def test_iam_policies(self): + """Tests for IAM policies""" + # assert Policy for pipeline S3 source + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": ["s3:GetObject*", "s3:GetBucket*", "s3:List*"], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "TemplateZipFileName"}, + ], + ] + }, + ], + }, + { + "Action": [ + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "MultiAccountPipelineSourceS3SourceCodePipelineActionRoleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "MultiAccountPipelineSourceS3SourceCodePipelineActionRole*" + ) + } + ], + }, + ) + + def test_iam_policies(self): + """Tests for IAM policies""" + # assert Policy for pipeline S3 source + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": ["s3:GetObject*", "s3:GetBucket*", "s3:List*"], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "TemplateZipFileName"}, + ], + ] + }, + ], + }, + { + "Action": [ + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "MultiAccountPipelineSourceS3SourceCodePipelineActionRoleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "MultiAccountPipelineSourceS3SourceCodePipelineActionRole*" + ) + } + ], + }, + ) + + def test_codepipeline(self): + """Tests for CodePipeline""" + # assert there is one codepipeline + self.template.resource_count_is("AWS::CodePipeline::Pipeline", 1) + + # assert properties + self.template.has_resource_properties( + "AWS::CodePipeline::Pipeline", + { + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MultiAccountPipelineRole*"), + "Arn", + ] + }, + "Stages": [ + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Source", + "Owner": "AWS", + "Provider": "S3", + "Version": "1", + }, + "Configuration": { + "S3Bucket": {"Ref": "AssetsBucket"}, + "S3ObjectKey": {"Ref": "TemplateZipFileName"}, + }, + "Name": "S3Source", + "OutputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineSourceS3SourceCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "Source", + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Invoke", + "Owner": "AWS", + "Provider": "Lambda", + "Version": "1", + }, + "Configuration": { + "FunctionName": { + "Ref": Match.string_like_regexp( + "DeployDevStackSetstacksetlambda*" + ) + }, + "UserParameters": { + "Fn::Join": [ + "", + [ + '{"stackset_name":"', + {"Ref": "StackName"}, + "-dev-", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + }, + ] + }, + ] + }, + ] + }, + ] + }, + '","artifact":"Artifact_Source_S3Source","template_file":"', + {"Ref": "TemplateFileName"}, + '","stage_params_file":"', + {"Ref": "DevParamsName"}, + '","account_ids":["', + {"Ref": "DevAccountId"}, + '"],"org_ids":["', + {"Ref": "DevOrgId"}, + '"],"regions":["', + {"Ref": "AWS::Region"}, + '"]}', + ], + ] + }, + }, + "InputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "Name": "DeployDevStackSet", + "Namespace": "DeployDevStackSet-namespace", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineDeployDevDeployDevStackSetCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + }, + { + "ActionTypeId": { + "Category": "Approval", + "Owner": "AWS", + "Provider": "Manual", + "Version": "1", + }, + "Configuration": { + "NotificationArn": { + "Ref": "NotificationsSNSTopicArn" + }, + "CustomData": "Please approve to deploy to staging account", + }, + "Name": "DeployStaging", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineDeployDevDeployStagingCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 2, + }, + ], + "Name": "DeployDev", + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Invoke", + "Owner": "AWS", + "Provider": "Lambda", + "Version": "1", + }, + "Configuration": { + "FunctionName": { + "Ref": Match.string_like_regexp( + "DeployStagingStackSetstacksetlambda*" + ) + }, + "UserParameters": { + "Fn::Join": [ + "", + [ + '{"stackset_name":"', + {"Ref": "StackName"}, + "-staging-", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + }, + ] + }, + ] + }, + ] + }, + ] + }, + '","artifact":"Artifact_Source_S3Source","template_file":"', + {"Ref": "TemplateFileName"}, + '","stage_params_file":"', + {"Ref": "StagingParamsName"}, + '","account_ids":["', + {"Ref": "StagingAccountId"}, + '"],"org_ids":["', + {"Ref": "StagingOrgId"}, + '"],"regions":["', + {"Ref": "AWS::Region"}, + '"]}', + ], + ] + }, + }, + "InputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "Name": "DeployStagingStackSet", + "Namespace": "DeployStagingStackSet-namespace", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineDeployStagingDeployStagingStackSetCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + }, + { + "ActionTypeId": { + "Category": "Approval", + "Owner": "AWS", + "Provider": "Manual", + "Version": "1", + }, + "Configuration": { + "NotificationArn": { + "Ref": "NotificationsSNSTopicArn" + }, + "CustomData": "Please approve to deploy to production account", + }, + "Name": "DeployProd", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineDeployStagingDeployProdCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 2, + }, + ], + "Name": "DeployStaging", + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Invoke", + "Owner": "AWS", + "Provider": "Lambda", + "Version": "1", + }, + "Configuration": { + "FunctionName": { + "Ref": Match.string_like_regexp( + "DeployProdStackSetstacksetlambda*" + ) + }, + "UserParameters": { + "Fn::Join": [ + "", + [ + '{"stackset_name":"', + {"Ref": "StackName"}, + "-prod-", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + "/", + { + "Ref": "AWS::StackId" + }, + ] + }, + ] + }, + ] + }, + ] + }, + '","artifact":"Artifact_Source_S3Source","template_file":"', + {"Ref": "TemplateFileName"}, + '","stage_params_file":"', + {"Ref": "ProdParamsName"}, + '","account_ids":["', + {"Ref": "ProdAccountId"}, + '"],"org_ids":["', + {"Ref": "ProdOrgId"}, + '"],"regions":["', + {"Ref": "AWS::Region"}, + '"]}', + ], + ] + }, + }, + "InputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "Name": "DeployProdStackSet", + "Namespace": "DeployProdStackSet-namespace", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MultiAccountPipelineDeployProdDeployProdStackSetCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "DeployProd", + }, + ], + "ArtifactStore": { + "Location": { + "Ref": Match.string_like_regexp( + "MultiAccountPipelineArtifactsBucket*" + ) + }, + "Type": "S3", + }, + }, + ) + + def test_events_rule(self): + """Tests for events Rules""" + # Rules to "Notify user of the outcome of the DeployDev|DeployStaging|DeployProd action" + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": Match.string_like_regexp( + "Notify user of the outcome of the (DeployDev|DeployStaging|DeployProd) action" + ), + "EventPattern": { + "detail": { + "state": ["SUCCEEDED", "FAILED"], + "stage": [ + Match.string_like_regexp( + "(DeployDev|DeployStaging|DeployProd)" + ) + ], + "action": [ + Match.string_like_regexp( + "(DeployDevStackSet|DeployStagingStackSet|DeployProdStackSet)" + ) + ], + }, + "detail-type": ["CodePipeline Action Execution State Change"], + "source": ["aws.codepipeline"], + "resources": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codepipeline:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "MultiAccountPipeline*" + ) + }, + ], + ] + } + ], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-action": "$.detail.action", + "detail-pipeline": "$.detail.pipeline", + "detail-state": "$.detail.state", + }, + "InputTemplate": Match.string_like_regexp( + '"(DeployDev|DeployStaging|DeployProd) action in the Pipeline finished executing. Action execution result is "' + ), + }, + } + ], + }, + ) + + # Rule "Notify user of the outcome of the pipeline" + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "Notify user of the outcome of the pipeline", + "EventPattern": { + "detail": {"state": ["SUCCEEDED", "FAILED"]}, + "source": ["aws.codepipeline"], + "resources": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codepipeline:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "MultiAccountPipeline*" + ) + }, + ], + ] + } + ], + "detail-type": ["CodePipeline Pipeline Execution State Change"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-pipeline": "$.detail.pipeline", + "detail-state": "$.detail.state", + }, + "InputTemplate": '"Pipeline finished executing. Pipeline execution result is "', + }, + } + ], + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + self.template.has_output( + "Pipelines", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://console.aws.amazon.com/codesuite/codepipeline/pipelines/", + {"Ref": Match.string_like_regexp("MultiAccountPipeline*")}, + "/view?region=", + {"Ref": "AWS::Region"}, + ], + ] + } + }, + ) diff --git a/source/infrastructure/test/ml_pipelines/test_realtime_inference_pipeline.py b/source/infrastructure/test/ml_pipelines/test_realtime_inference_pipeline.py new file mode 100644 index 0000000..1a64c2e --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_realtime_inference_pipeline.py @@ -0,0 +1,928 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.aspects.aws_sdk_config_aspect import AwsSDKConfigAspect +from lib.blueprints.aspects.protobuf_config_aspect import ProtobufConfigAspect +from lib.blueprints.ml_pipelines.realtime_inference_pipeline import ( + BYOMRealtimePipelineStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestRealtimeInference: + """Tests for realtime_inference_pipeline stack""" + + def setup_class(self): + """Tests setup""" + app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(app, "SolutionId") + version = get_cdk_context_value(app, "Version") + + realtime_stack = BYOMRealtimePipelineStack( + app, + "BYOMRealtimePipelineStack", + description=( + f"({solution_id}byom-rip) - BYOM Realtime Inference Pipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # add AWS_SDK_USER_AGENT env variable to Lambda functions + cdk.Aspects.of(realtime_stack).add( + AwsSDKConfigAspect(app, "SDKUserAgentSingle", solution_id, version) + ) + + # add PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python to handle protobuf breaking changes + cdk.Aspects.of(realtime_stack).add( + ProtobufConfigAspect(app, "ProtobufConfigSingle") + ) + + # create template + self.template = Template.from_stack(realtime_stack) + + def test_template_parameters(self): + """Tests for templates parameters""" + self.template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "BlueprintBucket", + { + "Type": "String", + "Description": "Bucket name for blueprints of different types of ML Pipelines.", + "MinLength": 3, + }, + ) + self.template.has_parameter( + "CustomAlgorithmsECRRepoArn", + { + "Type": "String", + "AllowedPattern": "(^arn:(aws|aws-cn|aws-us-gov):ecr:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:repository/.+|^$)", + "ConstraintDescription": "Please enter valid ECR repo ARN", + "Description": "The arn of the Amazon ECR repository where custom algorithm image is stored (optional)", + "MaxLength": 2048, + "MinLength": 0, + }, + ) + + self.template.has_parameter( + "KmsKeyArn", + { + "Type": "String", + "AllowedPattern": "(^arn:(aws|aws-cn|aws-us-gov):kms:(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\\d:\\d{12}:key/.+|^$)", + "ConstraintDescription": "Please enter kmsKey ARN", + "Description": "The KMS ARN to encrypt the output of the batch transform job and instance volume (optional).", + "MaxLength": 2048, + "MinLength": 0, + }, + ) + + self.template.has_parameter( + "ImageUri", + { + "Type": "String", + "Description": "The algorithm image uri (build-in or custom)", + }, + ) + + self.template.has_parameter( + "ModelName", + { + "Type": "String", + "Description": "An arbitrary name for the model.", + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "ModelArtifactLocation", + { + "Type": "String", + "Description": "Path to model artifact inside assets bucket.", + }, + ) + + self.template.has_parameter( + "DataCaptureLocation", + { + "Type": "String", + "Description": "S3 path (including bucket name) to store captured data from the Sagemaker endpoint.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "InferenceInstance", + { + "Type": "String", + "AllowedPattern": "^[a-zA-Z0-9_.+-]+\\.[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", + "Description": "Inference instance that inference requests will be running on. E.g., ml.m5.large", + "MinLength": 7, + }, + ) + + self.template.has_parameter( + "ModelPackageGroupName", + { + "Type": "String", + "Description": "SageMaker model package group name", + "MinLength": 0, + }, + ) + + self.template.has_parameter( + "ModelPackageName", + { + "Type": "String", + "AllowedPattern": "(^arn:aws[a-z\\-]*:sagemaker:[a-z0-9\\-]*:[0-9]{12}:model-package/.*|^$)", + "Description": "The model name (version arn) in SageMaker's model package name group", + }, + ) + + self.template.has_parameter( + "EndpointName", + { + "Type": "String", + "Description": "The name of the AWS SageMaker's endpoint", + "MinLength": 0, + }, + ) + + def test_template_conditions(self): + """Tests for templates conditions""" + self.template.has_condition( + "CustomECRRepoProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "CustomAlgorithmsECRRepoArn"}, ""]}]}, + ) + + self.template.has_condition( + "KmsKeyProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "KmsKeyArn"}, ""]}]}, + ) + + self.template.has_condition( + "ModelRegistryProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "ModelPackageName"}, ""]}]}, + ) + + self.template.has_condition( + "EndpointNameProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "EndpointName"}, ""]}]}, + ) + + def test_inference_lambda_policy(self): + """Tests for Inference Lambda policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + ], + "Effect": "Allow", + "Resource": "*", + }, + { + "Action": "sagemaker:InvokeEndpoint", + "Effect": "Allow", + "Resource": {"Ref": "MLOpsSagemakerEndpoint"}, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "BYOMInferenceLambdaFunctionServiceRoleDefaultPolicy*" + ), + }, + ) + + def test_inference_lambda_props(self): + """Test Inference Lambda props""" + self.template.has_resource_properties( + "AWS::Lambda::Function", + { + "Code": { + "S3Bucket": {"Ref": "BlueprintBucket"}, + "S3Key": "blueprints/lambdas/inference.zip", + }, + "Role": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "BYOMInferenceLambdaFunctionServiceRole*" + ), + "Arn", + ] + }, + "Environment": { + "Variables": { + "SAGEMAKER_ENDPOINT_NAME": { + "Fn::GetAtt": ["MLOpsSagemakerEndpoint", "EndpointName"] + }, + "AWS_SDK_USER_AGENT": '{"user_agent_extra": "AwsSolution/SO0136/%%VERSION%%"}', + "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION": "python", + } + }, + "Handler": "main.handler", + "Runtime": "python3.10", + "Timeout": 300, + "TracingConfig": {"Mode": "Active"}, + }, + ) + + self.template.has_resource( + "AWS::Lambda::Function", + { + "DependsOn": [ + Match.string_like_regexp( + "BYOMInferenceLambdaFunctionServiceRoleDefaultPolicy*" + ), + Match.string_like_regexp("BYOMInferenceLambdaFunctionServiceRole*"), + ], + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W89", + "reason": "The lambda function does not need to be attached to a vpc.", + }, + { + "id": "W58", + "reason": "The lambda functions role already has permissions to write cloudwatch logs", + }, + { + "id": "W92", + "reason": "The lambda function does need to define ReservedConcurrentExecutions", + }, + ] + } + }, + }, + ) + + def test_inference_api_gateway(self): + """Tests for Inference APIs""" + self.template.has_resource_properties( + "AWS::ApiGateway::RestApi", + { + "EndpointConfiguration": {"Types": ["EDGE"]}, + "Name": {"Fn::Join": ["", [{"Ref": "AWS::StackName"}, "-inference"]]}, + }, + ) + + self.template.has_resource_properties( + "AWS::ApiGateway::Deployment", + { + "RestApiId": { + "Ref": Match.string_like_regexp("BYOMInferenceLambdaRestApi*") + }, + "Description": "Automatically created by the RestApi construct", + }, + ) + + self.template.has_resource( + "AWS::ApiGateway::Deployment", + { + "DependsOn": [ + Match.string_like_regexp( + "BYOMInferenceLambdaRestApiinferencePOST*" + ), + Match.string_like_regexp("BYOMInferenceLambdaRestApiinference*"), + ] + }, + ) + + self.template.has_resource_properties( + "AWS::ApiGateway::Stage", + { + "RestApiId": { + "Ref": Match.string_like_regexp("BYOMInferenceLambdaRestApi*") + }, + "AccessLogSetting": { + "DestinationArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("BYOMInferenceApiAccessLogGroup*"), + "Arn", + ] + }, + "Format": '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","user":"$context.identity.user","caller":"$context.identity.caller","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength"}', + }, + "DeploymentId": { + "Ref": Match.string_like_regexp( + "BYOMInferenceLambdaRestApiDeployment*" + ) + }, + "MethodSettings": [ + { + "DataTraceEnabled": False, + "HttpMethod": "*", + "LoggingLevel": "INFO", + "ResourcePath": "/*", + } + ], + "StageName": "prod", + "TracingEnabled": True, + }, + ) + + self.template.has_resource_properties( + "AWS::ApiGateway::Resource", + { + "ParentId": { + "Fn::GetAtt": [ + Match.string_like_regexp("BYOMInferenceLambdaRestApi*"), + "RootResourceId", + ] + }, + "PathPart": "inference", + "RestApiId": { + "Ref": Match.string_like_regexp("BYOMInferenceLambdaRestApi*") + }, + }, + ) + + self.template.has_resource_properties( + "AWS::Lambda::Permission", + { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + Match.string_like_regexp("BYOMInferenceLambdaFunction*"), + "Arn", + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":execute-api:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "BYOMInferenceLambdaRestApi*" + ) + }, + "/", + { + "Ref": Match.string_like_regexp( + "BYOMInferenceLambdaRestApiDeploymentStageprod*" + ) + }, + "/POST/inference", + ], + ] + }, + }, + ) + + self.template.has_resource_properties( + "AWS::Lambda::Permission", + { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + Match.string_like_regexp("BYOMInferenceLambdaFunction*"), + "Arn", + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":execute-api:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "BYOMInferenceLambdaRestApi*" + ) + }, + "/test-invoke-stage/POST/inference", + ], + ] + }, + }, + ) + + self.template.has_resource_properties( + "AWS::ApiGateway::Method", + { + "HttpMethod": "POST", + "ResourceId": { + "Ref": Match.string_like_regexp( + "BYOMInferenceLambdaRestApiinference*" + ) + }, + "RestApiId": { + "Ref": Match.string_like_regexp("BYOMInferenceLambdaRestApi*") + }, + "AuthorizationType": "AWS_IAM", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":apigateway:", + {"Ref": "AWS::Region"}, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "BYOMInferenceLambdaFunction*" + ), + "Arn", + ] + }, + "/invocations", + ], + ] + }, + }, + }, + ) + + self.template.has_resource_properties( + "AWS::ApiGateway::Account", + { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "BYOMInferenceLambdaRestApiCloudWatchRole*" + ), + "Arn", + ] + } + }, + ) + + self.template.has_resource( + "AWS::ApiGateway::Account", + {"DependsOn": [Match.string_like_regexp("BYOMInferenceLambdaRestApi*")]}, + ) + + def test_api_cloudwatch_role(self): + """Test for Inference APIs role""" + self.template.has_resource_properties( + "AWS::IAM::Role", + { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": {"Service": "apigateway.amazonaws.com"}, + } + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "logs:PutLogEvents", + "logs:GetLogEvents", + "logs:FilterLogEvents", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":logs:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":*", + ], + ] + }, + } + ], + "Version": "2012-10-17", + }, + } + ], + }, + ) + + def test_ecr_policy(self): + """Test for MLOpd ECR policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:DescribeRepositories", + "ecr:DescribeImages", + "ecr:BatchGetImage", + ], + "Effect": "Allow", + "Resource": {"Ref": "CustomAlgorithmsECRRepoArn"}, + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*", + }, + ] + ), + "Version": "2012-10-17", + }, + }, + ) + + self.template.has_resource( + "AWS::IAM::Policy", + { + "Metadata": { + "cfn_nag": { + "rules_to_suppress": [ + { + "id": "W12", + "reason": "This ECR Policy (ecr:GetAuthorizationToken) can not have a restricted resource.", + } + ] + } + }, + "Condition": "CustomECRRepoProvided", + }, + ) + + def test_kms_policy(self): + """Tests for MLOps KMS key policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:CreateGrant", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ], + "Effect": "Allow", + "Resource": {"Ref": "KmsKeyArn"}, + } + ], + "Version": "2012-10-17", + }, + }, + ) + + self.template.has_resource("AWS::IAM::Policy", {"Condition": "KmsKeyProvided"}) + + def test_model_registry_policy(self): + """Tests for SageMaker Model Registry Policy""" + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": Match.array_equals( + [ + { + "Action": [ + "sagemaker:DescribeModelPackageGroup", + "sagemaker:DescribeModelPackage", + "sagemaker:ListModelPackages", + "sagemaker:UpdateModelPackage", + "sagemaker:CreateModel", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":model-package-group/", + {"Ref": "ModelPackageGroupName"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":model-package/", + {"Ref": "ModelPackageGroupName"}, + "/*", + ], + ] + }, + ], + } + ] + ), + "Version": "2012-10-17", + }, + }, + ) + + self.template.has_resource( + "AWS::IAM::Policy", {"Condition": "ModelRegistryProvided"} + ) + + def test_sagemaker_model(self): + """Tests SageMaker Model probs""" + self.template.has_resource_properties( + "AWS::SageMaker::Model", + { + "ExecutionRoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MLOpsRealtimeSagemakerRole*"), + "Arn", + ] + }, + "PrimaryContainer": { + "Image": { + "Fn::If": [ + "ModelRegistryProvided", + {"Ref": "AWS::NoValue"}, + {"Ref": "ImageUri"}, + ] + }, + "ModelDataUrl": { + "Fn::If": [ + "ModelRegistryProvided", + {"Ref": "AWS::NoValue"}, + { + "Fn::Join": [ + "", + [ + "s3://", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "ModelArtifactLocation"}, + ], + ] + }, + ] + }, + "ModelPackageName": { + "Fn::If": [ + "ModelRegistryProvided", + {"Ref": "ModelPackageName"}, + {"Ref": "AWS::NoValue"}, + ] + }, + }, + "Tags": [{"Key": "model_name", "Value": {"Ref": "ModelName"}}], + }, + ) + + self.template.has_resource( + "AWS::SageMaker::Model", + { + "DependsOn": [ + Match.string_like_regexp( + "MLOpsRealtimeSagemakerRoleDefaultPolicy*" + ), + Match.string_like_regexp("MLOpsRealtimeSagemakerRole*"), + ] + }, + ) + + def test_sagemaker_endpoint_config(self): + """ "Tests for SageMaker Endpoint Config""" + self.template.has_resource_properties( + "AWS::SageMaker::EndpointConfig", + { + "ProductionVariants": [ + { + "InitialInstanceCount": 1, + "InitialVariantWeight": 1, + "InstanceType": {"Ref": "InferenceInstance"}, + "ModelName": { + "Fn::GetAtt": ["MLOpsSagemakerModel", "ModelName"] + }, + "VariantName": "AllTraffic", + } + ], + "DataCaptureConfig": { + "CaptureContentTypeHeader": {"CsvContentTypes": ["text/csv"]}, + "CaptureOptions": [ + {"CaptureMode": "Output"}, + {"CaptureMode": "Input"}, + ], + "DestinationS3Uri": { + "Fn::Join": ["", ["s3://", {"Ref": "DataCaptureLocation"}]] + }, + "EnableCapture": True, + "InitialSamplingPercentage": 100, + "KmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + }, + "KmsKeyId": { + "Fn::If": [ + "KmsKeyProvided", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "Tags": [ + { + "Key": "endpoint-config-name", + "Value": { + "Fn::Join": ["", [{"Ref": "ModelName"}, "-endpoint-config"]] + }, + } + ], + }, + ) + + self.template.has_resource( + "AWS::SageMaker::EndpointConfig", + {"DependsOn": ["MLOpsSagemakerModel"]}, + ) + + def test_sagemaker_endpoint(self): + """Tests for SageMaker Endpoint""" + self.template.has_resource_properties( + "AWS::SageMaker::Endpoint", + { + "EndpointConfigName": { + "Fn::GetAtt": ["MLOpsSagemakerEndpointConfig", "EndpointConfigName"] + }, + "EndpointName": { + "Fn::If": [ + "EndpointNameProvided", + {"Ref": "EndpointName"}, + {"Ref": "AWS::NoValue"}, + ] + }, + "Tags": [ + { + "Key": "endpoint-name", + "Value": { + "Fn::Join": ["", [{"Ref": "ModelName"}, "-endpoint"]] + }, + } + ], + }, + ) + + self.template.has_resource( + "AWS::SageMaker::Endpoint", + {"DependsOn": ["MLOpsSagemakerEndpointConfig"]}, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + self.template.has_output( + "BYOMInferenceLambdaRestApiEndpoint1F9BE989", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": Match.string_like_regexp( + "BYOMInferenceLambdaRestApi*" + ) + }, + ".execute-api.", + {"Ref": "AWS::Region"}, + ".", + {"Ref": "AWS::URLSuffix"}, + "/", + { + "Ref": Match.string_like_regexp( + "BYOMInferenceLambdaRestApiDeploymentStageprod*" + ) + }, + "/", + ], + ] + } + }, + ) + + self.template.has_output( + "SageMakerModelName", + {"Value": {"Fn::GetAtt": ["MLOpsSagemakerModel", "ModelName"]}}, + ) + + self.template.has_output( + "SageMakerEndpointConfigName", + { + "Value": { + "Fn::GetAtt": ["MLOpsSagemakerEndpointConfig", "EndpointConfigName"] + } + }, + ) + + self.template.has_output( + "SageMakerEndpointName", + {"Value": {"Fn::GetAtt": ["MLOpsSagemakerEndpoint", "EndpointName"]}}, + ) + + self.template.has_output( + "EndpointDataCaptureLocation", + { + "Description": "Endpoint data capture location (to be used by Model Monitor)", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + {"Ref": "DataCaptureLocation"}, + "/", + ], + ] + }, + }, + ) diff --git a/source/infrastructure/test/ml_pipelines/test_single_account_codepipeline.py b/source/infrastructure/test/ml_pipelines/test_single_account_codepipeline.py new file mode 100644 index 0000000..5d77b18 --- /dev/null +++ b/source/infrastructure/test/ml_pipelines/test_single_account_codepipeline.py @@ -0,0 +1,617 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.blueprints.ml_pipelines.single_account_codepipeline import ( + SingleAccountCodePipelineStack, +) +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestSingleAccountCodePipelineStack: + """Tests for single_account_codepipeline stack""" + + def setup_class(self): + """Tests setup""" + self.app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(self.app, "SolutionId") + version = get_cdk_context_value(self.app, "Version") + + single_codepipeline = SingleAccountCodePipelineStack( + self.app, + "SingleAccountCodePipelineStack", + description=( + f"({solution_id}byom-sac) - Single-account codepipeline. Version {version}" + ), + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create template + self.template = Template.from_stack(single_codepipeline) + + def test_template_parameters(self): + """Tests for templates parameters""" + self.template.has_parameter( + "TemplateZipFileName", + { + "Type": "String", + "AllowedPattern": "^.*\\.zip$", + "Description": "The zip file's name containing the CloudFormation template and its parameters files", + }, + ) + + self.template.has_parameter( + "TemplateFileName", + { + "Type": "String", + "AllowedPattern": "^.*\\.yaml$", + "Description": "CloudFormation template's file name", + }, + ) + + self.template.has_parameter( + "TemplateParamsName", + { + "Type": "String", + "AllowedPattern": "^.*\\.json$", + "Description": "parameters json file's name for the main stage", + }, + ) + + self.template.has_parameter( + "AssetsBucket", + { + "Type": "String", + "Description": "Bucket name where the model and baselines data are stored.", + "MinLength": 3, + }, + ) + + self.template.has_parameter( + "StackName", + { + "Type": "String", + "Description": "The name to assign to the deployed CF stack.", + "MinLength": 1, + }, + ) + + self.template.has_parameter( + "NotificationsSNSTopicArn", + { + "Type": "String", + "AllowedPattern": "^arn:\\S+:sns:\\S+:\\d{12}:\\S+$", + "Description": "AWS SNS Topics arn used by the MLOps Workload Orchestrator to notify the administrator.", + }, + ) + + def test_all_s3_buckets_properties(self): + """Tests for S3 buckets properties""" + self.template.resource_count_is("AWS::S3::Bucket", 1) + # assert for all bucket, encryption is enabled and Public Access is Blocked + self.template.all_resources_properties( + "AWS::S3::Bucket", + { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + {"ServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}} + ] + }, + "PublicAccessBlockConfiguration": Match.object_equals( + { + "BlockPublicAcls": True, + "BlockPublicPolicy": True, + "IgnorePublicAcls": True, + "RestrictPublicBuckets": True, + } + ), + }, + ) + + # assert all S3 buckets are retained after stack is deleted + self.template.all_resources( + "AWS::S3::Bucket", + { + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + }, + ) + + def test_all_s3_buckets_policy(self): + """Tests for S3 buckets policies""" + # we have 1 S3 bucket, so we should have 1 bucket policies + self.template.resource_count_is("AWS::S3::BucketPolicy", 1) + # assert all buckets have bucket policy to enforce SecureTransport + self.template.all_resources_properties( + "AWS::S3::BucketPolicy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": "s3:*", + "Condition": {"Bool": {"aws:SecureTransport": "false"}}, + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + } + ] + ), + "Version": "2012-10-17", + } + }, + ) + + def test_codepipeline(self): + """Tests for CodePipeline""" + # assert there is one codepipeline + self.template.resource_count_is("AWS::CodePipeline::Pipeline", 1) + + # assert properties + self.template.has_resource_properties( + "AWS::CodePipeline::Pipeline", + { + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("SingleAccountPipelineRole*"), + "Arn", + ] + }, + "Stages": [ + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Source", + "Owner": "AWS", + "Provider": "S3", + "Version": "1", + }, + "Configuration": { + "S3Bucket": {"Ref": "AssetsBucket"}, + "S3ObjectKey": {"Ref": "TemplateZipFileName"}, + }, + "Name": "S3Source", + "OutputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineSourceS3SourceCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "Source", + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Deploy", + "Owner": "AWS", + "Provider": "CloudFormation", + "Version": "1", + }, + "Configuration": { + "StackName": {"Ref": "StackName"}, + "Capabilities": "CAPABILITY_NAMED_IAM", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineDeployCloudFormationdeploystackRole*" + ), + "Arn", + ] + }, + "TemplateConfiguration": { + "Fn::Join": [ + "", + [ + "Artifact_Source_S3Source::", + {"Ref": "TemplateParamsName"}, + ], + ] + }, + "ActionMode": "REPLACE_ON_FAILURE", + "TemplatePath": { + "Fn::Join": [ + "", + [ + "Artifact_Source_S3Source::", + {"Ref": "TemplateFileName"}, + ], + ] + }, + }, + "InputArtifacts": [ + {"Name": "Artifact_Source_S3Source"} + ], + "Name": "deploy_stack", + "Namespace": "deploy_stack-namespace", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineDeployCloudFormationdeploystackCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "DeployCloudFormation", + }, + ], + "ArtifactStore": { + "Location": { + "Ref": Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ) + }, + "Type": "S3", + }, + }, + ) + + # assert codepipeline dependencies + self.template.has_resource( + "AWS::CodePipeline::Pipeline", + { + "DependsOn": [ + Match.string_like_regexp("SingleAccountPipelineRoleDefaultPolicy*"), + Match.string_like_regexp("SingleAccountPipelineRole*"), + ] + }, + ) + + def test_iam_policies(self): + """Tests for IAM policies""" + # assert Policy for pipeline S3 source + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": ["s3:GetObject*", "s3:GetBucket*", "s3:List*"], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + {"Ref": "AssetsBucket"}, + "/", + {"Ref": "TemplateZipFileName"}, + ], + ] + }, + ], + }, + { + "Action": [ + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "SingleAccountPipelineSourceS3SourceCodePipelineActionRoleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "SingleAccountPipelineSourceS3SourceCodePipelineActionRole*" + ) + } + ], + }, + ) + + # assert Policy for Deploy stack stage + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineDeployCloudFormationdeploystackRole*" + ), + "Arn", + ] + }, + }, + { + "Action": ["s3:GetObject*", "s3:GetBucket*", "s3:List*"], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + }, + { + "Action": [ + "cloudformation:CreateStack", + "cloudformation:DeleteStack", + "cloudformation:DescribeStack*", + "cloudformation:GetStackPolicy", + "cloudformation:GetTemplate*", + "cloudformation:SetStackPolicy", + "cloudformation:UpdateStack", + "cloudformation:ValidateTemplate", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":cloudformation:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":stack/", + {"Ref": "StackName"}, + "/*", + ], + ] + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "SingleAccountPipelineDeployCloudFormationdeploystackCodePipelineActionRoleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "SingleAccountPipelineDeployCloudFormationdeploystackCodePipelineActionRole*" + ) + } + ], + }, + ) + + # assert Policy for Deploy stack stage default policy + self.template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": ["s3:GetObject*", "s3:GetBucket*", "s3:List*"], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "SingleAccountPipelineArtifactsBucket*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + }, + {"Action": "*", "Effect": "Allow", "Resource": "*"}, + ], + "Version": "2012-10-17", + }, + "PolicyName": Match.string_like_regexp( + "SingleAccountPipelineDeployCloudFormationdeploystackRoleDefaultPolicy*" + ), + "Roles": [ + { + "Ref": Match.string_like_regexp( + "SingleAccountPipelineDeployCloudFormationdeploystackRole*" + ) + } + ], + }, + ) + + def test_events_rule(self): + """Tests for events Rule""" + self.template.has_resource_properties( + "AWS::Events::Rule", + { + "Description": "Notify user of the outcome of the pipeline", + "EventPattern": { + "detail": {"state": ["SUCCEEDED", "FAILED"]}, + "source": ["aws.codepipeline"], + "resources": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codepipeline:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "SingleAccountPipeline*" + ) + }, + ], + ] + } + ], + "detail-type": ["CodePipeline Pipeline Execution State Change"], + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": {"Ref": "NotificationsSNSTopicArn"}, + "Id": "Target0", + "InputTransformer": { + "InputPathsMap": { + "detail-pipeline": "$.detail.pipeline", + "detail-state": "$.detail.state", + }, + "InputTemplate": '"Pipeline finished executing. Pipeline execution result is "', + }, + } + ], + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + self.template.has_output( + "Pipelines", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://console.aws.amazon.com/codesuite/codepipeline/pipelines/", + {"Ref": Match.string_like_regexp("SingleAccountPipeline*")}, + "/view?region=", + {"Ref": "AWS::Region"}, + ], + ] + } + }, + ) diff --git a/source/infrastructure/test/test_mlops_orchestrator_stack.py b/source/infrastructure/test/test_mlops_orchestrator_stack.py new file mode 100644 index 0000000..b213a64 --- /dev/null +++ b/source/infrastructure/test/test_mlops_orchestrator_stack.py @@ -0,0 +1,2096 @@ +# ##################################################################################################################### +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # +# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # +# and limitations under the License. # +# ##################################################################################################################### +import aws_cdk as cdk +from aws_cdk.assertions import Template, Match +from lib.mlops_orchestrator_stack import MLOpsStack +from lib.blueprints.pipeline_definitions.cdk_context_value import ( + get_cdk_context_value, +) +from test.context_helper import get_cdk_context + + +class TestMLOpsStacks: + """Tests for mlops_orchestrator_stack.py (single and multi account templates)""" + + def setup_class(self): + """Tests setup""" + self.app = cdk.App(context=get_cdk_context("././cdk.json")["context"]) + + solution_id = get_cdk_context_value(self.app, "SolutionId") + version = get_cdk_context_value(self.app, "Version") + + # create single account stack + single_mlops_stack = MLOpsStack( + self.app, + "mlops-workload-orchestrator-single-account", + description=f"({solution_id}-sa) - MLOps Workload Orchestrator (Single Account Option). Version {version}", + multi_account=False, + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create multi account stack + multi_mlops_stack = MLOpsStack( + self.app, + "mlops-workload-orchestrator-multi-account", + description=f"({solution_id}-ma) - MLOps Workload Orchestrator (Multi Account Option). Version {version}", + multi_account=True, + synthesizer=cdk.DefaultStackSynthesizer( + generate_bootstrap_version_rule=False + ), + ) + + # create templates + self.template_single = Template.from_stack(single_mlops_stack) + self.template_multi = Template.from_stack(multi_mlops_stack) + # all templates + self.templates = [self.template_single, self.template_multi] + + def test_template_parameters(self): + """Tests for templates parameters""" + # Template parameters shared across single and multi account + for template in self.templates: + template.has_parameter( + "NotificationEmail", + { + "Type": "String", + "AllowedPattern": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", + "ConstraintDescription": "Please enter an email address with correct format (example@example.com)", + "Description": "email for pipeline outcome notifications", + "MaxLength": 320, + "MinLength": 5, + }, + ) + + template.has_parameter( + "CodeCommitRepoAddress", + { + "Type": "String", + "AllowedPattern": "^(((https:\\/\\/|ssh:\\/\\/)(git\\-codecommit)\\.[a-zA-Z0-9_.+-]+(amazonaws\\.com\\/)[a-zA-Z0-9-.]+(\\/)[a-zA-Z0-9-.]+(\\/)[a-zA-Z0-9-.]+$)|^$)", + "ConstraintDescription": "CodeCommit address must follow the pattern: ssh or https://git-codecommit.REGION.amazonaws.com/version/repos/REPONAME", + "Description": "AWS CodeCommit repository clone URL to connect to the framework.", + "MaxLength": 320, + "MinLength": 0, + }, + ) + + template.has_parameter( + "ExistingS3Bucket", + { + "Type": "String", + "AllowedPattern": "((?=^.{3,63}$)(?!^(\\d+\\.)+\\d+$)(^(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])$)|^$)", + "Description": "Name of existing S3 bucket to be used for ML assets. S3 Bucket must be in the same region as the deployed stack, and has versioning enabled. If not provided, a new S3 bucket will be created.", + "MaxLength": 63, + "MinLength": 0, + }, + ) + + template.has_parameter( + "ExistingECRRepo", + { + "Type": "String", + "AllowedPattern": "((?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)*[a-z0-9]+(?:[._-][a-z0-9]+)*|^$)", + "Description": "Name of existing Amazon ECR repository for custom algorithms. If not provided, a new ECR repo will be created.", + "MaxLength": 63, + "MinLength": 0, + }, + ) + + template.has_parameter( + "UseModelRegistry", + { + "Type": "String", + "Default": "No", + "AllowedValues": ["Yes", "No"], + "Description": "Will Amazon SageMaker's Model Registry be used to provision models?", + }, + ) + + template.has_parameter( + "CreateModelRegistry", + { + "Type": "String", + "Default": "No", + "AllowedValues": ["Yes", "No"], + "Description": "Do you want the solution to create the SageMaker Model Package Group Name (i.e., Model Registry)", + }, + ) + + template.has_parameter( + "AllowDetailedErrorMessage", + { + "Type": "String", + "Default": "Yes", + "AllowedValues": ["Yes", "No"], + "Description": "Allow including a detailed message of any server-side errors in the API call's response", + }, + ) + + # Parameters only for multi account template + self.template_multi.has_parameter( + "DelegatedAdminAccount", + { + "Type": "String", + "Default": "Yes", + "AllowedValues": ["Yes", "No"], + "Description": "Is a delegated administrator account used to deploy across account", + }, + ) + + self.template_multi.has_parameter( + "DevAccountId", + { + "Type": "String", + "AllowedPattern": "^\\d{12}$", + "Description": "AWS development account number where the CF template will be deployed", + }, + ) + + self.template_multi.has_parameter( + "DevOrgId", + { + "Type": "String", + "AllowedPattern": "^ou-[0-9a-z]{4,32}-[a-z0-9]{8,32}$", + "Description": "AWS development organizational unit id where the CF template will be deployed", + }, + ) + + self.template_multi.has_parameter( + "StagingAccountId", + { + "Type": "String", + "AllowedPattern": "^\\d{12}$", + "Description": "AWS staging account number where the CF template will be deployed", + }, + ) + + self.template_multi.has_parameter( + "StagingOrgId", + { + "Type": "String", + "AllowedPattern": "^ou-[0-9a-z]{4,32}-[a-z0-9]{8,32}$", + "Description": "AWS staging organizational unit id where the CF template will be deployed", + }, + ) + + self.template_multi.has_parameter( + "ProdAccountId", + { + "Type": "String", + "AllowedPattern": "^\\d{12}$", + "Description": "AWS production account number where the CF template will be deployed", + }, + ) + + self.template_multi.has_parameter( + "ProdOrgId", + { + "Type": "String", + "AllowedPattern": "^ou-[0-9a-z]{4,32}-[a-z0-9]{8,32}$", + "Description": "AWS production organizational unit id where the CF template will be deployed", + }, + ) + + def test_template_conditions(self): + """Tests for templates conditions""" + # single and multi account templates should have the same conditions + for template in self.templates: + template.has_condition( + "GitAddressProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "CodeCommitRepoAddress"}, ""]}]}, + ) + + template.has_condition( + "S3BucketProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "ExistingS3Bucket"}, ""]}]}, + ) + + template.has_condition( + "ECRProvided", + {"Fn::Not": [{"Fn::Equals": [{"Ref": "ExistingECRRepo"}, ""]}]}, + ) + + template.has_condition( + "CreateModelRegistryCondition", + {"Fn::Equals": [{"Ref": "CreateModelRegistry"}, "Yes"]}, + ) + + template.has_condition( + "CreateS3Bucket", {"Fn::Equals": [{"Ref": "ExistingS3Bucket"}, ""]} + ) + + template.has_condition( + "CreateECRRepo", {"Fn::Equals": [{"Ref": "ExistingECRRepo"}, ""]} + ) + + template.has_condition( + "AnonymizedDatatoAWS", + { + "Fn::Equals": [ + { + "Fn::FindInMap": [ + "AnonymizedData", + "SendAnonymizedData", + "Data", + ] + }, + "Yes", + ] + }, + ) + + def test_all_s3_buckets_properties(self): + """Tests for S3 buckets properties""" + for template in self.templates: + template.resource_count_is("AWS::S3::Bucket", 4) + # assert for all bucket, encryption is enabled and Public Access is Blocked + template.all_resources_properties( + "AWS::S3::Bucket", + { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": Match.string_like_regexp( + "(AES256|aws:kms)" + ) + } + } + ] + }, + "PublicAccessBlockConfiguration": Match.object_equals( + { + "BlockPublicAcls": True, + "BlockPublicPolicy": True, + "IgnorePublicAcls": True, + "RestrictPublicBuckets": True, + } + ), + }, + ) + + # assert all S3 buckets are retained after stack is deleted + template.all_resources( + "AWS::S3::Bucket", + { + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + }, + ) + + # assert for blueprints and assets buckets logging is configured + template.resource_properties_count_is( + "AWS::S3::Bucket", + { + "LoggingConfiguration": { + "DestinationBucketName": { + "Ref": Match.string_like_regexp("accessLogs*") + }, + "LogFilePrefix": Match.string_like_regexp( + "(assets_bucket_access_logs|blueprint-repository*)" + ), + } + }, + 2, + ) + + def test_all_s3_buckets_policy(self): + """Tests for S3 buckets policies""" + for template in self.templates: + # we have 4 S3 buckets, so we should have 4 bucket policies + template.resource_count_is("AWS::S3::BucketPolicy", 4) + # assert all buckets have bucket policy to enforce SecureTransport + template.all_resources_properties( + "AWS::S3::BucketPolicy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": "s3:*", + "Condition": { + "Bool": {"aws:SecureTransport": "false"} + }, + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "(accessLogs*|blueprintrepository*|pipelineassets*|MLOpsCodeCommitPipelineArtifactsBucket)" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "(accessLogs*|blueprintrepository*|pipelineassets*|MLOpsCodeCommitPipelineArtifactsBucket)" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + } + ] + ), + "Version": "2012-10-17", + } + }, + ) + + # assert the access logging bucket has permissions for blueprints and assets buckets + template.resource_properties_count_is( + "AWS::S3::BucketPolicy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": "s3:PutObject", + "Condition": { + "ArnLike": { + "aws:SourceArn": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + { + "Fn::If": [ + "S3BucketProvided", + { + "Ref": "ExistingS3Bucket" + }, + { + "Ref": Match.string_like_regexp( + "pipelineassets*" + ) + }, + ] + }, + ], + ] + }, + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "blueprintrepository*" + ), + "Arn", + ] + }, + ] + }, + "StringEquals": { + "aws:SourceAccount": { + "Ref": "AWS::AccountId" + } + }, + }, + "Effect": "Allow", + "Principal": { + "Service": "logging.s3.amazonaws.com" + }, + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "accessLogs*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + } + ] + ), + "Version": "2012-10-17", + } + }, + 1, + ) + + # for multi account, assert that the Blueprints and assets buckets policies give permissions to + # dev, staging, and prod accounts + self.template_multi.resource_properties_count_is( + "AWS::S3::BucketPolicy", + { + "PolicyDocument": { + "Statement": Match.array_with( + [ + { + "Action": ["s3:GetObject", "s3:ListBucket"], + "Effect": "Allow", + "Principal": { + "AWS": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "DevAccountId"}, + ":root", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "StagingAccountId"}, + ":root", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "ProdAccountId"}, + ":root", + ], + ] + }, + ] + }, + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "(blueprintrepository*|pipelineassets*)" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "(blueprintrepository*|pipelineassets*)" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + ], + }, + ] + ), + "Version": "2012-10-17", + } + }, + 2, + ) + + def test_template_ecr_repo(self): + for template in self.templates: + # assert there is only ECR repo, which has ScanOnPush enabled + template.resource_properties_count_is( + "AWS::ECR::Repository", + { + "ImageScanningConfiguration": Match.object_equals( + {"ScanOnPush": True} + ) + }, + 1, + ) + + # assert the ECR repo has the expected other properties + template.has_resource( + "AWS::ECR::Repository", + { + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Condition": "CreateECRRepo", + }, + ) + + # for multi account, assert the ECR repo has resource policy to grant permissions to + # dev, staging, and prod accounts + self.template_multi.resource_properties_count_is( + "AWS::ECR::Repository", + { + "RepositoryPolicyText": { + "Statement": [ + { + "Action": [ + "ecr:DescribeImages", + "ecr:DescribeRepositories", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability", + ], + "Effect": "Allow", + "Principal": { + "AWS": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "DevAccountId"}, + ":root", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "StagingAccountId"}, + ":root", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "ProdAccountId"}, + ":root", + ], + ] + }, + ] + }, + } + ], + "Version": "2012-10-17", + } + }, + 1, + ) + + def test_template_custom_resource_uuid(self): + """Tests for UUID custom resource""" + for template in self.templates: + # assert the count + template.resource_count_is("Custom::CreateUUID", 1) + + # assert custom resource properties + template.has_resource_properties( + "Custom::CreateUUID", + { + "ServiceToken": { + "Fn::GetAtt": [ + Match.string_like_regexp("SolutionHelper*"), + "Arn", + ] + }, + "Resource": "UUID", + "CreateModelRegistry": {"Ref": "CreateModelRegistry"}, + }, + ) + + def test_template_custom_resource_anonymized_data(self): + """Tests for Anonymized data custom resource""" + for template in self.templates: + # assert the count + template.resource_count_is("Custom::AnonymizedData", 1) + # assert custom resource properties + template.has_resource_properties( + "Custom::AnonymizedData", + { + "ServiceToken": { + "Fn::GetAtt": [ + Match.string_like_regexp("SolutionHelper*"), + "Arn", + ] + }, + "Resource": "AnonymizedMetric", + "UUID": {"Fn::GetAtt": ["CreateUniqueID", "UUID"]}, + "bucketSelected": {"Fn::If": ["S3BucketProvided", "True", "False"]}, + "gitSelected": {"Fn::If": ["GitAddressProvided", "True", "False"]}, + "Region": {"Ref": "AWS::Region"}, + "IsMultiAccount": Match.string_like_regexp( + "(False|True)" + ), # single account=False, multi=True + "IsDelegatedAccount": { + "Ref": Match.string_like_regexp( + "(AWS::NoValue|DelegatedAdminAccount)" + ) + }, # single account=AWS::NoValue, multi=DelegatedAdminAccount + "UseModelRegistry": {"Ref": "UseModelRegistry"}, + "SolutionId": "SO0136", + "Version": "%%VERSION%%", + }, + ) + + # assert the custom resource other properties + template.has_resource( + "Custom::AnonymizedData", + { + "Type": "Custom::AnonymizedData", + "Properties": Match.any_value(), + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Condition": "AnonymizedDatatoAWS", + }, + ) + + def test_custom_resource_copy_assets(self): + """Tests for copy assets custom resource""" + for template in self.templates: + # assert the is only one custom resource + template.resource_count_is("AWS::CloudFormation::CustomResource", 1) + + # assert properties + template.has_resource_properties( + "AWS::CloudFormation::CustomResource", + { + "ServiceToken": { + "Fn::GetAtt": [ + Match.string_like_regexp("CustomResourceLambda*"), + "Arn", + ] + } + }, + ) + + # assert other properties + template.has_resource( + "AWS::CloudFormation::CustomResource", + { + "DependsOn": [ + Match.string_like_regexp("blueprintrepository(.*)Policy*"), + Match.string_like_regexp("blueprintrepository*"), + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + }, + ) + + def test_orchestrator_policy(self): + """Tests for Lambda orchestrator policy""" + for template in self.templates: + template.has_resource_properties( + "AWS::IAM::Policy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "cloudformation:CreateStack", + "cloudformation:DeleteStack", + "cloudformation:UpdateStack", + "cloudformation:DescribeStacks", + "cloudformation:ListStackResources", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":cloudformation:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":stack/mlops-pipeline*/*", + ], + ] + }, + }, + { + "Action": [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:PassRole", + "iam:PutRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:UntagRole", + "iam:TagRole", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "AWS::AccountId"}, + ":role/mlops-pipeline*", + ], + ] + }, + }, + { + "Action": [ + "ecr:CreateRepository", + "ecr:DescribeRepositories", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":ecr:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":repository/", + { + "Fn::If": [ + "ECRProvided", + {"Ref": "ExistingECRRepo"}, + {"Ref": "ECRRepoC36DC9E6"}, + ] + }, + ], + ] + }, + }, + { + "Action": [ + "codebuild:CreateProject", + "codebuild:DeleteProject", + "codebuild:BatchGetProjects", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codebuild:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":project/ContainerFactory*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codebuild:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":project/VerifySagemaker*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codebuild:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":report-group/*", + ], + ] + }, + ], + }, + { + "Action": [ + "lambda:CreateFunction", + "lambda:DeleteFunction", + "lambda:InvokeFunction", + "lambda:PublishLayerVersion", + "lambda:DeleteLayerVersion", + "lambda:GetLayerVersion", + "lambda:GetFunctionConfiguration", + "lambda:GetFunction", + "lambda:AddPermission", + "lambda:RemovePermission", + "lambda:UpdateFunctionConfiguration", + "lambda:TagResource", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":lambda:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":layer:*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":lambda:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":function:*", + ], + ] + }, + ], + }, + { + "Action": ["s3:GetObject", "s3:ListBucket"], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "blueprintrepository*" + ), + "Arn", + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + { + "Fn::If": [ + "S3BucketProvided", + {"Ref": "ExistingS3Bucket"}, + { + "Ref": Match.string_like_regexp( + "pipelineassets*" + ) + }, + ] + }, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + Match.string_like_regexp( + "blueprintrepository*" + ), + "Arn", + ] + }, + "/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + { + "Fn::If": [ + "S3BucketProvided", + {"Ref": "ExistingS3Bucket"}, + { + "Ref": Match.string_like_regexp( + "pipelineassets*" + ) + }, + ] + }, + "/*", + ], + ] + }, + ], + }, + { + "Action": [ + "servicecatalog:CreateApplication", + "servicecatalog:GetApplication", + "servicecatalog:UpdateApplication", + "servicecatalog:DeleteApplication", + "servicecatalog:CreateAttributeGroup", + "servicecatalog:GetAttributeGroup", + "servicecatalog:UpdateAttributeGroup", + "servicecatalog:DeleteAttributeGroup", + "servicecatalog:AssociateResource", + "servicecatalog:DisassociateResource", + "servicecatalog:AssociateAttributeGroup", + "servicecatalog:DisassociateAttributeGroup", + "servicecatalog:TagResource", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":servicecatalog:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":/applications/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":servicecatalog:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":/attribute-groups/*", + ], + ] + }, + ], + }, + { + "Action": [ + "codepipeline:CreatePipeline", + "codepipeline:UpdatePipeline", + "codepipeline:DeletePipeline", + "codepipeline:GetPipeline", + "codepipeline:GetPipelineState", + "codepipeline:TagResource", + "codepipeline:UntagResource", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codepipeline:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":mlops-pipeline*", + ], + ] + }, + }, + { + "Action": [ + "apigateway:POST", + "apigateway:PATCH", + "apigateway:DELETE", + "apigateway:GET", + "apigateway:PUT", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":apigateway:", + {"Ref": "AWS::Region"}, + "::/restapis/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":apigateway:", + {"Ref": "AWS::Region"}, + "::/restapis", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":apigateway:", + {"Ref": "AWS::Region"}, + "::/account", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":apigateway:", + {"Ref": "AWS::Region"}, + "::/usageplans", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":apigateway:", + {"Ref": "AWS::Region"}, + "::/usageplans/*", + ], + ] + }, + ], + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:DescribeLogGroups", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":logs:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":log-group:*", + ], + ] + }, + }, + { + "Action": [ + "s3:CreateBucket", + "s3:PutEncryptionConfiguration", + "s3:PutBucketVersioning", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketLogging", + "s3:GetBucketPolicy", + "s3:PutBucketPolicy", + "s3:DeleteBucketPolicy", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + ["arn:", {"Ref": "AWS::Partition"}, ":s3:::*"], + ] + }, + }, + { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":s3:::", + { + "Fn::If": [ + "S3BucketProvided", + {"Ref": "ExistingS3Bucket"}, + { + "Ref": Match.string_like_regexp( + "pipelineassets*" + ) + }, + ] + }, + "/*", + ], + ] + }, + }, + { + "Action": [ + "sns:CreateTopic", + "sns:DeleteTopic", + "sns:Subscribe", + "sns:Unsubscribe", + "sns:GetTopicAttributes", + "sns:SetTopicAttributes", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":sns:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":mlops-pipeline*-*PipelineNotification*", + ], + ] + }, + }, + { + "Action": [ + "events:PutRule", + "events:DescribeRule", + "events:PutTargets", + "events:RemoveTargets", + "events:DeleteRule", + "events:PutEvents", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":events:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":rule/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":events:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":event-bus/*", + ], + ] + }, + ], + }, + { + "Action": [ + "sagemaker:CreateModelCard", + "sagemaker:DescribeModelCard", + "sagemaker:UpdateModelCard", + "sagemaker:DeleteModelCard", + "sagemaker:CreateModelCardExportJob", + "sagemaker:DescribeModelCardExportJob", + "sagemaker:DescribeModel", + "sagemaker:DescribeTrainingJob", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":sagemaker:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":model-card/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":sagemaker:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":model/*", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":sagemaker:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":training-job/*", + ], + ] + }, + ], + }, + { + "Action": [ + "sagemaker:ListModelCards", + "sagemaker:Search", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + }, + ) + + def test_template_sns(self): + """Tests for sns topic""" + for template in self.templates: + # assert template has one SNS Topic + template.resource_count_is("AWS::SNS::Topic", 1) + + # assert there one SNS subscription with these properties + template.resource_properties_count_is( + "AWS::SNS::Subscription", + { + "Protocol": "email", + "TopicArn": { + "Ref": Match.string_like_regexp("MLOpsNotificationsTopic*") + }, + "Endpoint": {"Ref": "NotificationEmail"}, + }, + 1, + ) + + # assert there one Topic Policy with these properties + template.resource_properties_count_is( + "AWS::SNS::TopicPolicy", + { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Resource": { + "Ref": Match.string_like_regexp( + "MLOpsNotificationsTopic*" + ) + }, + "Sid": "0", + } + ], + "Version": "2012-10-17", + }, + "Topics": [ + {"Ref": Match.string_like_regexp("MLOpsNotificationsTopic*")} + ], + }, + 1, + ) + + def test_sagemaker_sdk_layer(self): + """Test for SageMaker SDK Lambda layer""" + for template in self.templates: + # assert there is only one layer + template.resource_count_is("AWS::Lambda::LayerVersion", 1) + # assert layer properties + template.has_resource_properties( + "AWS::Lambda::LayerVersion", + { + "Content": { + "S3Bucket": { + "Ref": Match.string_like_regexp("blueprintrepository*") + }, + "S3Key": "blueprints/lambdas/sagemaker_layer.zip", + }, + "CompatibleRuntimes": ["python3.9", "python3.10"], + }, + ) + # assert layer's dependency + template.has_resource( + "AWS::Lambda::LayerVersion", {"DependsOn": ["CustomResourceCopyAssets"]} + ) + + def test_api_gateway(self): + """Test for API Gateway""" + for template in self.templates: + # assert template has one Rest API + template.resource_count_is("AWS::ApiGateway::RestApi", 1) + + # assert API properties + template.has_resource_properties( + "AWS::ApiGateway::RestApi", + { + "EndpointConfiguration": {"Types": ["EDGE"]}, + "Name": { + "Fn::Join": ["", [{"Ref": "AWS::StackName"}, "-orchestrator"]] + }, + }, + ) + + # assert template has one API Deployment + template.resource_count_is("AWS::ApiGateway::Deployment", 1) + + # assert API deployment properties + template.has_resource_properties( + "AWS::ApiGateway::Deployment", + { + "RestApiId": { + "Ref": Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApi*" + ) + }, + "Description": "Automatically created by the RestApi construct", + }, + ) + + # assert API deployment dependencies + template.has_resource( + "AWS::ApiGateway::Deployment", + { + "DependsOn": [ + Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApipipelinestatusPOST*" + ), + Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApipipelinestatus*" + ), + Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApiprovisionpipelinePOST*" + ), + Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApiprovisionpipeline*" + ), + ] + }, + ) + + # assert API gateway has permissions to invoke the orchestrator Lambda + template.has_resource_properties( + "AWS::Lambda::Permission", + { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "PipelineOrchestrationLambdaFunction*" + ), + "Arn", + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":execute-api:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApi*" + ) + }, + "/", + { + "Ref": Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApiDeploymentStageprod*" + ) + }, + "/POST/provisionpipeline", + ], + ] + }, + }, + ) + + # assert all methods has Authorization Type "AWS_IAM" + template.all_resources_properties( + "AWS::ApiGateway::Method", + { + "AuthorizationType": "AWS_IAM", + }, + ) + + # assert we have two APIs resources: /pipelinestatus and /provisionpipeline + template.has_resource_properties( + "AWS::ApiGateway::Resource", + { + "PathPart": Match.string_like_regexp( + "(pipelinestatus|provisionpipeline)" + ), + }, + ) + + def test_events_rule(self): + """Tests for events Rule""" + for template in self.templates: + template.has_resource_properties( + "AWS::Events::Rule", + { + "EventPattern": { + "source": ["aws.codecommit"], + "resources": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codecommit:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + "/", + { + "Ref": "CodeCommitRepoAddress" + }, + ] + }, + ] + }, + ], + ] + } + ], + "detail-type": ["CodeCommit Repository State Change"], + "detail": { + "event": ["referenceCreated", "referenceUpdated"], + "referenceName": ["main"], + }, + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":codepipeline:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":", + { + "Ref": Match.string_like_regexp( + "MLOpsCodeCommitPipeline*" + ) + }, + ], + ] + }, + "Id": "Target0", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MLOpsCodeCommitPipelineEventsRole*" + ), + "Arn", + ] + }, + } + ], + }, + ) + + def test_codebuild(self): + """Tests for Codebuild""" + for template in self.templates: + template.has_resource_properties( + "AWS::CodeBuild::Project", + { + "Artifacts": {"Type": "CODEPIPELINE"}, + "Environment": Match.object_equals( + { + "ComputeType": "BUILD_GENERAL1_SMALL", + "Image": "aws/codebuild/standard:1.0", + "ImagePullCredentialsType": "CODEBUILD", + "PrivilegedMode": False, + "Type": "LINUX_CONTAINER", + } + ), + "ServiceRole": { + "Fn::GetAtt": ["TakeconfigfileRoleD1BE5721", "Arn"] + }, + "Source": { + "BuildSpec": { + "Fn::Join": [ + "", + [ + '{\n "version": "0.2",\n "phases": {\n "build": {\n "commands": [\n "ls -a",\n "aws lambda invoke --function-name ', + { + "Ref": "PipelineOrchestrationLambdaFunction7EE5E931" + }, + ' --payload fileb://mlops-config.json response.json --invocation-type RequestResponse"\n ]\n }\n }\n}', + ], + ] + }, + "Type": "CODEPIPELINE", + }, + "Cache": {"Type": "NO_CACHE"}, + "EncryptionKey": "alias/aws/s3", + }, + ) + + def test_codepipeline(self): + """Tests for CodePipeline""" + for template in self.templates: + # assert there is one codepipeline + template.resource_count_is("AWS::CodePipeline::Pipeline", 1) + + # assert properties + template.has_resource_properties( + "AWS::CodePipeline::Pipeline", + { + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp("MLOpsCodeCommitPipelineRole*"), + "Arn", + ] + }, + "Stages": [ + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Source", + "Owner": "AWS", + "Provider": "CodeCommit", + "Version": "1", + }, + "Configuration": Match.object_equals( + { + "RepositoryName": { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + "/", + { + "Ref": "CodeCommitRepoAddress" + }, + ] + }, + ] + }, + "BranchName": "main", + "PollForSourceChanges": False, + } + ), + "Name": "CodeCommit", + "OutputArtifacts": [ + {"Name": "Artifact_Source_CodeCommit"} + ], + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MLOpsCodeCommitPipelineSourceCodeCommitCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "Source", + }, + { + "Actions": [ + { + "ActionTypeId": { + "Category": "Build", + "Owner": "AWS", + "Provider": "CodeBuild", + "Version": "1", + }, + "Configuration": { + "ProjectName": { + "Ref": Match.string_like_regexp( + "Takeconfigfile*" + ) + } + }, + "InputArtifacts": [ + {"Name": "Artifact_Source_CodeCommit"} + ], + "Name": "provision_pipeline", + "RoleArn": { + "Fn::GetAtt": [ + Match.string_like_regexp( + "MLOpsCodeCommitPipelineTakeConfigprovisionpipelineCodePipelineActionRole*" + ), + "Arn", + ] + }, + "RunOrder": 1, + } + ], + "Name": "TakeConfig", + }, + ], + "ArtifactStore": { + "Location": { + "Ref": Match.string_like_regexp( + "MLOpsCodeCommitPipelineArtifactsBucket*" + ) + }, + "Type": "S3", + }, + }, + ) + + # assert codepipeline dependencies and condition + template.has_resource( + "AWS::CodePipeline::Pipeline", + { + "DependsOn": [ + Match.string_like_regexp( + "MLOpsCodeCommitPipelineRoleDefaultPolicy*" + ), + Match.string_like_regexp("MLOpsCodeCommitPipelineRole*"), + ], + "Condition": "GitAddressProvided", + }, + ) + + def test_sagemaker_model_registry(self): + """Tests for SageMaker Model Registry""" + for template in self.templates: + # assert template has one ModelPackageGroup + template.resource_count_is("AWS::SageMaker::ModelPackageGroup", 1) + + # assert registry properties + template.has_resource_properties( + "AWS::SageMaker::ModelPackageGroup", + { + "ModelPackageGroupName": { + "Fn::Join": [ + "", + [ + "mlops-model-registry-", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::GetAtt": [ + "CreateUniqueID", + "UUID", + ] + }, + ] + }, + ] + }, + ], + ] + }, + "ModelPackageGroupDescription": "SageMaker model package group name (model registry) for mlops", + "Tags": [{"Key": "stack-name", "Value": {"Ref": "AWS::StackName"}}], + }, + ) + + # assert the model registry other properties, dependency and condition + template.has_resource( + "AWS::SageMaker::ModelPackageGroup", + { + "DependsOn": ["CreateUniqueID"], + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Condition": "CreateModelRegistryCondition", + }, + ) + + # for multi account, assert the model registry resource policy grants permissions + # to dev, staging and prod accounts + self.template_multi.has_resource_properties( + "AWS::SageMaker::ModelPackageGroup", + { + "ModelPackageGroupPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AddPermModelPackageGroup", + "Effect": "Allow", + "Principal": { + "AWS": [ + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "DevAccountId"}, + ":root", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "StagingAccountId"}, + ":root", + ], + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":iam::", + {"Ref": "ProdAccountId"}, + ":root", + ], + ] + }, + ] + }, + "Action": [ + "sagemaker:DescribeModelPackageGroup", + "sagemaker:DescribeModelPackage", + "sagemaker:ListModelPackages", + "sagemaker:UpdateModelPackage", + "sagemaker:CreateModel", + ], + "Resource": [ + { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":model-package-group/mlops-model-registry-", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::GetAtt": [ + "CreateUniqueID", + "UUID", + ] + }, + ] + }, + ] + }, + ], + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::Sub": [ + "arn:${PARTITION}:sagemaker:${REGION}:${ACCOUNT_ID}", + { + "PARTITION": { + "Ref": "AWS::Partition" + }, + "REGION": { + "Ref": "AWS::Region" + }, + "ACCOUNT_ID": { + "Ref": "AWS::AccountId" + }, + }, + ] + }, + ":model-package/mlops-model-registry-", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "-", + { + "Fn::GetAtt": [ + "CreateUniqueID", + "UUID", + ] + }, + ] + }, + ] + }, + "/*", + ], + ] + }, + ], + } + ], + }, + }, + ) + + def test_template_outputs(self): + """Tests for templates outputs""" + # both single and multi templates should have teh same outputs + for template in self.templates: + template.has_output( + "PipelineOrchestrationLambdaRestApiEndpoint9B628338", + { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApi*" + ) + }, + ".execute-api.", + {"Ref": "AWS::Region"}, + ".", + {"Ref": "AWS::URLSuffix"}, + "/", + { + "Ref": Match.string_like_regexp( + "PipelineOrchestrationLambdaRestApiDeploymentStageprod*" + ) + }, + "/", + ], + ] + } + }, + ) + + template.has_output( + "BlueprintsBucket", + { + "Description": "S3 Bucket to upload MLOps Framework Blueprints", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + { + "Ref": Match.string_like_regexp( + "blueprintrepository*" + ) + }, + ], + ] + }, + }, + ) + + template.has_output( + "AssetsBucket", + { + "Description": "S3 Bucket to upload model artifact", + "Value": { + "Fn::Join": [ + "", + [ + "https://s3.console.aws.amazon.com/s3/buckets/", + { + "Fn::If": [ + "S3BucketProvided", + {"Ref": "ExistingS3Bucket"}, + { + "Ref": Match.string_like_regexp( + "pipelineassets*" + ) + }, + ] + }, + ], + ] + }, + }, + ) + + template.has_output( + "ECRRepoName", + { + "Description": "Amazon ECR repository's name", + "Value": { + "Fn::If": [ + "ECRProvided", + {"Ref": "ExistingECRRepo"}, + {"Ref": Match.string_like_regexp("ECRRepo*")}, + ] + }, + }, + ) + + template.has_output( + "ECRRepoArn", + { + "Description": "Amazon ECR repository's arn", + "Value": { + "Fn::If": [ + "ECRProvided", + { + "Fn::Join": [ + "", + [ + "arn:", + {"Ref": "AWS::Partition"}, + ":ecr:", + {"Ref": "AWS::Region"}, + ":", + {"Ref": "AWS::AccountId"}, + ":repository/", + {"Ref": "ExistingECRRepo"}, + ], + ] + }, + { + "Fn::GetAtt": [ + Match.string_like_regexp("ECRRepo*"), + "Arn", + ] + }, + ] + }, + }, + ) + + template.has_output( + "ModelRegistryArn", + { + "Description": "SageMaker model package group arn", + "Value": { + "Fn::If": [ + "CreateModelRegistryCondition", + { + "Fn::GetAtt": [ + "SageMakerModelRegistry", + "ModelPackageGroupArn", + ] + }, + "[No Model Package Group was created]", + ] + }, + }, + ) + + template.has_output( + "MLOpsNotificationsTopicArn", + { + "Description": "MLOps notifications SNS topic arn.", + "Value": { + "Ref": Match.string_like_regexp("MLOpsNotificationsTopic*") + }, + }, + ) diff --git a/source/lambdas/pipeline_orchestration/lambda_helpers.py b/source/lambdas/pipeline_orchestration/lambda_helpers.py index 9a5d32d..bdc8673 100644 --- a/source/lambdas/pipeline_orchestration/lambda_helpers.py +++ b/source/lambdas/pipeline_orchestration/lambda_helpers.py @@ -24,7 +24,11 @@ logger = get_logger(__name__) -TRAINING_PIPELINES = ["model_autopilot_training", "model_training_builtin", "model_tuner_builtin"] +TRAINING_PIPELINES = [ + "model_autopilot_training", + "model_training_builtin", + "model_tuner_builtin", +] @exception_handler @@ -49,32 +53,34 @@ def template_url(pipeline_type: str) -> str: single_account_codepipeline.yaml multi_account_codepipeline.yaml """ - url = "https://" + os.environ["BLUEPRINT_BUCKET_URL"] + "/blueprints/byom" - realtime_inference_template = "blueprints/byom/byom_realtime_inference_pipeline.yaml" - batch_inference_template = "blueprints/byom/byom_batch_pipeline.yaml" + url = "https://" + os.environ["BLUEPRINT_BUCKET_URL"] + "/blueprints" + realtime_inference_template = "blueprints/byom_realtime_inference_pipeline.yaml" + batch_inference_template = "blueprints/byom_batch_pipeline.yaml" templates_map = { "byom_realtime_builtin": realtime_inference_template, "byom_realtime_custom": realtime_inference_template, "byom_batch_builtin": batch_inference_template, "byom_batch_custom": batch_inference_template, - "byom_data_quality_monitor": "blueprints/byom/byom_data_quality_monitor.yaml", - "byom_model_quality_monitor": "blueprints/byom/byom_model_quality_monitor.yaml", - "byom_model_bias_monitor": "blueprints/byom/byom_model_bias_monitor.yaml", - "byom_model_explainability_monitor": "blueprints/byom/byom_model_explainability_monitor.yaml", + "byom_data_quality_monitor": "blueprints/byom_data_quality_monitor.yaml", + "byom_model_quality_monitor": "blueprints/byom_model_quality_monitor.yaml", + "byom_model_bias_monitor": "blueprints/byom_model_bias_monitor.yaml", + "byom_model_explainability_monitor": "blueprints/byom_model_explainability_monitor.yaml", "byom_image_builder": f"{url}/byom_custom_algorithm_image_builder.yaml", "single_account_codepipeline": f"{url}/single_account_codepipeline.yaml", "multi_account_codepipeline": f"{url}/multi_account_codepipeline.yaml", - "model_training_builtin": "blueprints/byom/model_training_pipeline.yaml", - "model_tuner_builtin": "blueprints/byom/model_hyperparameter_tunning_pipeline.yaml", - "model_autopilot_training": "blueprints/byom/autopilot_training_pipeline.yaml", + "model_training_builtin": "blueprints/model_training_pipeline.yaml", + "model_tuner_builtin": "blueprints/model_hyperparameter_tunning_pipeline.yaml", + "model_autopilot_training": "blueprints/autopilot_training_pipeline.yaml", } if pipeline_type in list(templates_map.keys()): return templates_map[pipeline_type] else: - raise BadRequest(f"Bad request. Pipeline type: {pipeline_type} is not supported.") + raise BadRequest( + f"Bad request. Pipeline type: {pipeline_type} is not supported." + ) @exception_handler @@ -109,16 +115,22 @@ def get_stack_name(event: Dict[str, Any]) -> str: } # stack name's infix - infix = event.get("image_tag") if pipeline_type == "byom_image_builder" else model_name + infix = ( + event.get("image_tag") if pipeline_type == "byom_image_builder" else model_name + ) # name of stack - provisioned_pipeline_stack_name = f"{pipeline_stack_name}-{infix}-{postfix[pipeline_type]}" + provisioned_pipeline_stack_name = ( + f"{pipeline_stack_name}-{infix}-{postfix[pipeline_type]}" + ) return provisioned_pipeline_stack_name.lower() @exception_handler -def get_template_parameters(event: Dict[str, Any], is_multi_account: bool, stage: str = None) -> List[Tuple[str, str]]: +def get_template_parameters( + event: Dict[str, Any], is_multi_account: bool, stage: str = None +) -> List[Tuple[str, str]]: pipeline_type = event.get("pipeline_type") region = os.environ["REGION"] @@ -163,35 +175,55 @@ def get_template_parameters(event: Dict[str, Any], is_multi_account: bool, stage "byom_realtime_custom": realtime_params, "byom_batch_builtin": batch_params, "byom_batch_custom": batch_params, - "byom_data_quality_monitor": [*common_params, *get_model_monitor_params(event, region, stage)] + "byom_data_quality_monitor": [ + *common_params, + *get_model_monitor_params(event, region, stage), + ] if pipeline_type == "byom_data_quality_monitor" else None, "byom_model_quality_monitor": [ *common_params, - *get_model_monitor_params(event, region, stage, monitoring_type="ModelQuality"), + *get_model_monitor_params( + event, region, stage, monitoring_type="ModelQuality" + ), ] if pipeline_type == "byom_model_quality_monitor" else None, "byom_model_bias_monitor": [ *common_params, - *get_model_monitor_params(event, region, stage, monitoring_type="ModelBias"), + *get_model_monitor_params( + event, region, stage, monitoring_type="ModelBias" + ), ] if pipeline_type == "byom_model_bias_monitor" else None, "byom_model_explainability_monitor": [ *common_params, - *get_model_monitor_params(event, region, stage, monitoring_type="ModelExplainability"), + *get_model_monitor_params( + event, region, stage, monitoring_type="ModelExplainability" + ), ] if pipeline_type == "byom_model_explainability_monitor" else None, - "byom_image_builder": [*get_image_builder_params(event)] if pipeline_type == "byom_image_builder" else None, - "model_autopilot_training": [*common_params, *get_autopilot_specifc_params(event, job_name)] + "byom_image_builder": [*get_image_builder_params(event)] + if pipeline_type == "byom_image_builder" + else None, + "model_autopilot_training": [ + *common_params, + *get_autopilot_specifc_params(event, job_name), + ] if pipeline_type == "model_autopilot_training" else None, - "model_training_builtin": [*common_params, *get_model_training_specifc_params(event, job_name)] + "model_training_builtin": [ + *common_params, + *get_model_training_specifc_params(event, job_name), + ] if pipeline_type == "model_training_builtin" else None, - "model_tuner_builtin": [*common_params, *get_model_tuner_specifc_params(event, job_name)] + "model_tuner_builtin": [ + *common_params, + *get_model_tuner_specifc_params(event, job_name), + ] if pipeline_type == "model_tuner_builtin" else None, } @@ -208,9 +240,12 @@ def get_template_parameters(event: Dict[str, Any], is_multi_account: bool, stage @exception_handler def get_codepipeline_params( - is_multi_account: str, pipeline_type: str, stack_name: str, template_zip_name: str, template_file_name: str + is_multi_account: str, + pipeline_type: str, + stack_name: str, + template_zip_name: str, + template_file_name: str, ) -> List[Tuple[str, str]]: - single_account_params = [ ("NotificationsSNSTopicArn", os.environ["MLOPS_NOTIFICATIONS_SNS_TOPIC"]), ("TemplateZipFileName", template_zip_name), @@ -247,10 +282,14 @@ def get_codepipeline_params( @exception_handler -def get_common_realtime_batch_params(event: Dict[str, Any], region: str, stage: str) -> List[Tuple[str, str]]: +def get_common_realtime_batch_params( + event: Dict[str, Any], region: str, stage: str +) -> List[Tuple[str, str]]: inference_instance = get_stage_param(event, "inference_instance", stage) image_uri = ( - get_image_uri(event.get("pipeline_type"), event, region) if os.environ["USE_MODEL_REGISTRY"] == "No" else "" + get_image_uri(event.get("pipeline_type"), event, region) + if os.environ["USE_MODEL_REGISTRY"] == "No" + else "" ) model_package_group_name = ( # model_package_name example: arn:aws:sagemaker:us-east-1::model-package/xgboost/1 @@ -280,16 +319,27 @@ def clean_param(param: str) -> str: @exception_handler -def get_realtime_specific_params(event: Dict[str, Any], stage: str) -> List[Tuple[str, str]]: - data_capture_location = clean_param(get_stage_param(event, "data_capture_location", stage)) +def get_realtime_specific_params( + event: Dict[str, Any], stage: str +) -> List[Tuple[str, str]]: + data_capture_location = clean_param( + get_stage_param(event, "data_capture_location", stage) + ) endpoint_name = get_stage_param(event, "endpoint_name", stage).lower().strip() - return [("DataCaptureLocation", data_capture_location), ("EndpointName", endpoint_name)] + return [ + ("DataCaptureLocation", data_capture_location), + ("EndpointName", endpoint_name), + ] @exception_handler -def get_batch_specific_params(event: Dict[str, Any], stage: str) -> List[Tuple[str, str]]: +def get_batch_specific_params( + event: Dict[str, Any], stage: str +) -> List[Tuple[str, str]]: batch_inference_data = get_stage_param(event, "batch_inference_data", stage) - batch_job_output_location = clean_param(get_stage_param(event, "batch_job_output_location", stage)) + batch_job_output_location = clean_param( + get_stage_param(event, "batch_job_output_location", stage) + ) return [ ("BatchInputBucket", batch_inference_data.split("/")[0]), ("BatchInferenceData", batch_inference_data), @@ -316,31 +366,57 @@ def get_model_monitor_params( # generate jobs names # make sure baseline_job_name and monitoring_schedule_name are <= 63 characters long, especially # if endpoint_name was dynamically generated by AWS CDK. - baseline_job_name = f"{endpoint_name}-{monitoring_type.lower()}-{str(uuid.uuid4())[:4]}" - monitoring_schedule_name = f"{endpoint_name}-{monitoring_type.lower()}-{str(uuid.uuid4())[:4]}" + baseline_job_name = ( + f"{endpoint_name}-{monitoring_type.lower()}-{str(uuid.uuid4())[:4]}" + ) + monitoring_schedule_name = ( + f"{endpoint_name}-{monitoring_type.lower()}-{str(uuid.uuid4())[:4]}" + ) - baseline_job_output_location = clean_param(get_stage_param(event, "baseline_job_output_location", stage)) - data_capture_location = clean_param(get_stage_param(event, "data_capture_location", stage)) + baseline_job_output_location = clean_param( + get_stage_param(event, "baseline_job_output_location", stage) + ) + data_capture_location = clean_param( + get_stage_param(event, "data_capture_location", stage) + ) instance_type = get_stage_param(event, "instance_type", stage) instance_volume_size = str(get_stage_param(event, "instance_volume_size", stage)) - baseline_max_runtime_seconds = str(get_stage_param(event, "baseline_max_runtime_seconds", stage)) - monitor_max_runtime_seconds = str(get_stage_param(event, "monitor_max_runtime_seconds", stage)) - monitoring_output_location = clean_param(get_stage_param(event, "monitoring_output_location", stage)) + baseline_max_runtime_seconds = str( + get_stage_param(event, "baseline_max_runtime_seconds", stage) + ) + monitor_max_runtime_seconds = str( + get_stage_param(event, "monitor_max_runtime_seconds", stage) + ) + monitoring_output_location = clean_param( + get_stage_param(event, "monitoring_output_location", stage) + ) schedule_expression = get_stage_param(event, "schedule_expression", stage) - monitor_ground_truth_input = get_stage_param(event, "monitor_ground_truth_input", stage) + monitor_ground_truth_input = get_stage_param( + event, "monitor_ground_truth_input", stage + ) # set the framework based on the monitoring type # DataQuality/ModelQuality -> framework="model-monitor" # ModelBias/ModelExplanability -> framework="clarify" - monitor_framework = "model-monitor" if monitoring_type in ["DataQuality", "ModelQuality"] else "clarify" + monitor_framework = ( + "model-monitor" + if monitoring_type in ["DataQuality", "ModelQuality"] + else "clarify" + ) monitor_params = [ ("BaselineJobName", baseline_job_name), ("BaselineOutputBucket", baseline_job_output_location.split("/")[0]), - ("BaselineJobOutputLocation", f"{baseline_job_output_location}/{baseline_job_name}"), + ( + "BaselineJobOutputLocation", + f"{baseline_job_output_location}/{baseline_job_name}", + ), ("DataCaptureBucket", data_capture_location.split("/")[0]), ("DataCaptureLocation", data_capture_location), ("EndpointName", endpoint_name), - ("ImageUri", get_built_in_model_monitor_image_uri(region, framework=monitor_framework)), + ( + "ImageUri", + get_built_in_model_monitor_image_uri(region, framework=monitor_framework), + ), ("InstanceType", instance_type), ("InstanceVolumeSize", instance_volume_size), ("BaselineMaxRuntimeSeconds", baseline_max_runtime_seconds), @@ -355,9 +431,18 @@ def get_model_monitor_params( if monitoring_type == "ModelQuality": monitor_params.extend( [ - ("BaselineInferenceAttribute", event.get("baseline_inference_attribute", "").strip()), - ("BaselineProbabilityAttribute", event.get("baseline_probability_attribute", "").strip()), - ("BaselineGroundTruthAttribute", event.get("baseline_ground_truth_attribute", "").strip()), + ( + "BaselineInferenceAttribute", + event.get("baseline_inference_attribute", "").strip(), + ), + ( + "BaselineProbabilityAttribute", + event.get("baseline_probability_attribute", "").strip(), + ), + ( + "BaselineGroundTruthAttribute", + event.get("baseline_ground_truth_attribute", "").strip(), + ), ] ) # add ModelQuality parameters, also used by ModelBias/Model @@ -365,15 +450,26 @@ def get_model_monitor_params( monitor_params.extend( [ ("ProblemType", event.get("problem_type", "").strip()), - ("MonitorInferenceAttribute", event.get("monitor_inference_attribute", "").strip()), - ("MonitorProbabilityAttribute", event.get("monitor_probability_attribute", "").strip()), - ("ProbabilityThresholdAttribute", event.get("probability_threshold_attribute", "").strip()), + ( + "MonitorInferenceAttribute", + event.get("monitor_inference_attribute", "").strip(), + ), + ( + "MonitorProbabilityAttribute", + event.get("monitor_probability_attribute", "").strip(), + ), + ( + "ProbabilityThresholdAttribute", + event.get("probability_threshold_attribute", "").strip(), + ), ] ) # only add MonitorGroundTruthInput if ModelQuality|ModelBias if monitoring_type in ["ModelQuality", "ModelBias"]: - monitor_params.append(("GroundTruthBucket", monitor_ground_truth_input.split("/")[0])) + monitor_params.append( + ("GroundTruthBucket", monitor_ground_truth_input.split("/")[0]) + ) monitor_params.append(("MonitorGroundTruthInput", monitor_ground_truth_input)) # add ModelBias specific params @@ -383,7 +479,9 @@ def get_model_monitor_params( [ ( "ModelPredictedLabelConfig", - json.dumps(model_predicted_label_config) if model_predicted_label_config else "", + json.dumps(model_predicted_label_config) + if model_predicted_label_config + else "", ), ("BiasConfig", json.dumps(event.get("bias_config"))), ] @@ -396,13 +494,15 @@ def get_model_monitor_params( monitor_params.extend( [ ("SHAPConfig", json.dumps(shap_config) if shap_config else ""), - ("ExplainabilityModelScores", json.dumps(model_scores) if model_scores else ""), + ( + "ExplainabilityModelScores", + json.dumps(model_scores) if model_scores else "", + ), ] ) # add common params for ModelBias/ModelExplainability if monitoring_type in ["ModelBias", "ModelExplainability"]: - monitor_params.extend( [ ("FeaturesAttribute", event.get("features_attribute", "").strip()), @@ -424,7 +524,9 @@ def get_image_builder_params(event: Dict[str, Any]) -> List[Tuple[str, str]]: @exception_handler -def get_autopilot_specifc_params(event: Dict[str, Any], job_name: str) -> List[Tuple[str, str]]: +def get_autopilot_specifc_params( + event: Dict[str, Any], job_name: str +) -> List[Tuple[str, str]]: return [ ("NotificationsSNSTopicArn", os.environ["MLOPS_NOTIFICATIONS_SNS_TOPIC"]), ("JobName", job_name), @@ -443,11 +545,16 @@ def get_autopilot_specifc_params(event: Dict[str, Any], job_name: str) -> List[T @exception_handler -def get_model_training_specifc_params(event: Dict[str, Any], job_name: str) -> List[Tuple[str, str]]: +def get_model_training_specifc_params( + event: Dict[str, Any], job_name: str +) -> List[Tuple[str, str]]: return [ ("NotificationsSNSTopicArn", os.environ["MLOPS_NOTIFICATIONS_SNS_TOPIC"]), ("JobName", job_name), - ("ImageUri", get_image_uri(event.get("pipeline_type"), event, os.environ["REGION"])), + ( + "ImageUri", + get_image_uri(event.get("pipeline_type"), event, os.environ["REGION"]), + ), ("InstanceType", event.get("instance_type", "ml.m4.xlarge")), ("JobInstanceCount", str(event.get("instance_count", "1"))), ("InstanceVolumeSize", str(event.get("instance_volume_size", "20"))), @@ -457,7 +564,10 @@ def get_model_training_specifc_params(event: Dict[str, Any], job_name: str) -> L ("EncryptInnerTraffic", event.get("encrypt_inner_traffic", "True")), ("MaxRuntimePerJob", str(event.get("max_runtime_per_job", "86400"))), ("UseSpotInstances", event.get("use_spot_instances", "True")), - ("MaxWaitTimeForSpotInstances", str(event.get("max_wait_time_spot_instances", "172800"))), + ( + "MaxWaitTimeForSpotInstances", + str(event.get("max_wait_time_spot_instances", "172800")), + ), ("ContentType", event.get("content_type", "csv")), ("S3DataType", event.get("s3_data_type", "S3Prefix")), ("DataDistribution", event.get("data_distribution", "FullyReplicated")), @@ -470,7 +580,9 @@ def get_model_training_specifc_params(event: Dict[str, Any], job_name: str) -> L @exception_handler -def get_model_tuner_specifc_params(event: Dict[str, Any], job_name: str) -> List[Tuple[str, str]]: +def get_model_tuner_specifc_params( + event: Dict[str, Any], job_name: str +) -> List[Tuple[str, str]]: return [ *get_model_training_specifc_params(event, job_name), ("HyperparametersTunerConfig", json.dumps(event.get("tuner_configs"))), @@ -485,7 +597,10 @@ def format_template_parameters( if is_multi_account == "True": # for the multi-account option, the StackSet action, used by multi-account codepipeline, # requires this parameters format - return [{"ParameterKey": param[0], "ParameterValue": param[1]} for param in key_value_list] + return [ + {"ParameterKey": param[0], "ParameterValue": param[1]} + for param in key_value_list + ] else: # for single account option, the CloudFormation action, used by single-account codepipeline, # requires this parameters format @@ -493,18 +608,24 @@ def format_template_parameters( @exception_handler -def write_params_to_json(params: Union[List[Dict[str, str]], Dict[str, Dict[str, str]]], file_path: str) -> None: +def write_params_to_json( + params: Union[List[Dict[str, str]], Dict[str, Dict[str, str]]], file_path: str +) -> None: with open(file_path, "w") as fp: json.dump(params, fp, indent=4) @exception_handler -def upload_file_to_s3(local_file_path: str, s3_bucket_name: str, s3_file_key: str, s3_client: BaseClient) -> None: +def upload_file_to_s3( + local_file_path: str, s3_bucket_name: str, s3_file_key: str, s3_client: BaseClient +) -> None: s3_client.upload_file(local_file_path, s3_bucket_name, s3_file_key) @exception_handler -def download_file_from_s3(s3_bucket_name: str, file_key: str, local_file_path: str, s3_client: BaseClient) -> None: +def download_file_from_s3( + s3_bucket_name: str, file_key: str, local_file_path: str, s3_client: BaseClient +) -> None: s3_client.download_file(s3_bucket_name, file_key, local_file_path) @@ -532,15 +653,27 @@ def create_template_zip_file( zip_file_path = os.path.join(zip_local_directory, zip_output_filename) # download the template from the blueprints bucket - download_file_from_s3(blueprint_bucket, template_url, f"{local_directory}/{template_url.split('/')[-1]}", s3_client) + download_file_from_s3( + blueprint_bucket, + template_url, + f"{local_directory}/{template_url.split('/')[-1]}", + s3_client, + ) # write the params to json file(s) - if is_multi_account == "True" and event.get("pipeline_type") not in TRAINING_PIPELINES: + if ( + is_multi_account == "True" + and event.get("pipeline_type") not in TRAINING_PIPELINES + ): for stage in ["dev", "staging", "prod"]: # format the template params stage_params_list = get_template_parameters(event, is_multi_account, stage) - params_formated = format_template_parameters(stage_params_list, is_multi_account) - write_params_to_json(params_formated, f"{local_directory}/{stage}_template_params.json") + params_formated = format_template_parameters( + stage_params_list, is_multi_account + ) + write_params_to_json( + params_formated, f"{local_directory}/{stage}_template_params.json" + ) else: stage_params_list = get_template_parameters(event, "False") params_formated = format_template_parameters(stage_params_list, "False") @@ -573,15 +706,18 @@ def get_image_uri(pipeline_type: str, event: Dict[str, Any], region: str) -> str "model_tuner_builtin", ]: return sagemaker.image_uris.retrieve( - framework=event.get("model_framework"), region=region, version=event.get("model_framework_version") + framework=event.get("model_framework"), + region=region, + version=event.get("model_framework_version"), ) else: raise ValueError("Unsupported pipeline by get_image_uri function") @exception_handler -def get_required_keys(pipeline_type: str, use_model_registry: str, problem_type: str = None) -> List[str]: - +def get_required_keys( + pipeline_type: str, use_model_registry: str, problem_type: str = None +) -> List[str]: common_keys = ["pipeline_type", "model_name", "inference_instance"] model_location = ["model_artifact_location"] builtin_model_keys = ["model_framework", "model_framework_version"] + model_location @@ -605,13 +741,19 @@ def get_required_keys(pipeline_type: str, use_model_registry: str, problem_type: batch_specific_keys = ["batch_inference_data", "batch_job_output_location"] # model monitor keys - monitors = ["byom_model_quality_monitor", "byom_model_bias_monitor", "byom_model_explainability_monitor"] + monitors = [ + "byom_model_quality_monitor", + "byom_model_bias_monitor", + "byom_model_explainability_monitor", + ] if pipeline_type in monitors and problem_type not in [ "Regression", "MulticlassClassification", "BinaryClassification", ]: - raise BadRequest("Bad request format. Unsupported problem_type in byom_model_quality_monitor pipeline") + raise BadRequest( + "Bad request format. Unsupported problem_type in byom_model_quality_monitor pipeline" + ) # common required keys between model monitor types common_monitor_keys = [ @@ -629,7 +771,10 @@ def get_required_keys(pipeline_type: str, use_model_registry: str, problem_type: ] # ModelQuality specific keys - model_quality_keys = ["baseline_inference_attribute", "baseline_ground_truth_attribute"] + model_quality_keys = [ + "baseline_inference_attribute", + "baseline_ground_truth_attribute", + ] # common model related monitors common_model_keys = ["problem_type"] # add required keys based on problem type @@ -655,8 +800,16 @@ def get_required_keys(pipeline_type: str, use_model_registry: str, problem_type: # create pipeline_type -> required_keys map pipeline_keys_map = { - "byom_realtime_builtin": [*common_keys, *builtin_model_keys, *realtime_specific_keys], - "byom_realtime_custom": [*common_keys, *custom_model_keys, *realtime_specific_keys], + "byom_realtime_builtin": [ + *common_keys, + *builtin_model_keys, + *realtime_specific_keys, + ], + "byom_realtime_custom": [ + *common_keys, + *custom_model_keys, + *realtime_specific_keys, + ], "byom_batch_builtin": [*common_keys, *builtin_model_keys, *batch_specific_keys], "byom_batch_custom": [*common_keys, *custom_model_keys, *batch_specific_keys], "byom_data_quality_monitor": common_monitor_keys, @@ -678,7 +831,12 @@ def get_required_keys(pipeline_type: str, use_model_registry: str, problem_type: *common_model_keys, "shap_config", ], - "byom_image_builder": ["pipeline_type", "custom_algorithm_docker", "ecr_repo_name", "image_tag"], + "byom_image_builder": [ + "pipeline_type", + "custom_algorithm_docker", + "ecr_repo_name", + "image_tag", + ], "model_training_builtin": model_training_keys, "model_tuner_builtin": model_tuner_keys, "model_autopilot_training": autopilot_keys, @@ -708,11 +866,15 @@ def validate(event: Dict[str, Any]) -> Dict[str, Any]: """ # get the required keys to validate the event required_keys = get_required_keys( - event.get("pipeline_type", "").strip(), os.environ["USE_MODEL_REGISTRY"], event.get("problem_type", "").strip() + event.get("pipeline_type", "").strip(), + os.environ["USE_MODEL_REGISTRY"], + event.get("problem_type", "").strip(), ) for key in required_keys: if key not in event: logger.error(f"Request event did not have parameter: {key}") - raise BadRequest(f"Bad request. API body does not have the necessary parameter: {key}") + raise BadRequest( + f"Bad request. API body does not have the necessary parameter: {key}" + ) return event diff --git a/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py b/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py index 56d2d05..30e8297 100644 --- a/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py +++ b/source/lambdas/pipeline_orchestration/tests/fixtures/orchestrator_fixtures.py @@ -73,7 +73,11 @@ def _api_byom_event(pipeline_type, is_multi=False, endpint_name_provided=False): "False": os.environ["INSTANCETYPE"], }, batch_job_output_location={ - "True": {"dev": "bucket/dev_output", "staging": "bucket/staging_output", "prod": "bucket/prod_output"}, + "True": { + "dev": "bucket/dev_output", + "staging": "bucket/staging_output", + "prod": "bucket/prod_output", + }, "False": os.environ["BATCHOUTPUT"], }, data_capture_location={ @@ -103,10 +107,14 @@ def _api_byom_event(pipeline_type, is_multi=False, endpint_name_provided=False): if pipeline_type in ["byom_batch_builtin", "byom_batch_custom"]: event["batch_inference_data"] = os.environ["INFERENCEDATA"] - event["batch_job_output_location"] = maping["batch_job_output_location"][str(is_multi)] + event["batch_job_output_location"] = maping["batch_job_output_location"][ + str(is_multi) + ] if pipeline_type in ["byom_realtime_builtin", "byom_realtime_custom"]: - event["data_capture_location"] = maping["data_capture_location"][str(is_multi)] + event["data_capture_location"] = maping["data_capture_location"][ + str(is_multi) + ] # add optional endpoint_name if endpint_name_provided: event["endpoint_name"] = maping["endpoint_name"][str(is_multi)] @@ -203,7 +211,12 @@ def _api_training_event(pipeline_type): "training_data": "train/data.csv", "target_attribute": "target", "job_output_location": "training-output", - "algo_hyperparamaters": dict(eval_metric="auc", objective="binary:logistic", num_round=400, rate_drop=0.3), + "algo_hyperparamaters": dict( + eval_metric="auc", + objective="binary:logistic", + num_round=400, + rate_drop=0.3, + ), "tuner_configs": dict( early_stopping_type="Auto", objective_metric_name="validation:auc", @@ -259,7 +272,10 @@ def expected_data_quality_monitor_params(): ("DataCaptureBucket", "testbucket"), ("DataCaptureLocation", os.environ["BASELINEOUTPUT"]), ("EndpointName", "test_endpoint"), - ("ImageUri", "156813124566.dkr.ecr.us-east-1.amazonaws.com/sagemaker-model-monitor-analyzer"), + ( + "ImageUri", + "156813124566.dkr.ecr.us-east-1.amazonaws.com/sagemaker-model-monitor-analyzer", + ), ("InstanceType", os.environ["INSTANCETYPE"]), ("InstanceVolumeSize", "20"), ("BaselineMaxRuntimeSeconds", "3600"), @@ -350,7 +366,10 @@ def expected_image_builder_params(): def expected_realtime_specific_params(): def _expected_realtime_specific_params(endpoint_name_provided=False): endpoint_name = "test-endpoint" if endpoint_name_provided else "" - return [("DataCaptureLocation", os.environ["DATACAPTURE"]), ("EndpointName", endpoint_name)] + return [ + ("DataCaptureLocation", os.environ["DATACAPTURE"]), + ("EndpointName", endpoint_name), + ] return _expected_realtime_specific_params @@ -381,7 +400,10 @@ def stack_id(): @pytest.fixture def expected_multi_account_params_format(): return [ - {"ParameterKey": "NotificationsSNSTopicArn", "ParameterValue": os.environ["MLOPS_NOTIFICATIONS_SNS_TOPIC"]}, + { + "ParameterKey": "NotificationsSNSTopicArn", + "ParameterValue": os.environ["MLOPS_NOTIFICATIONS_SNS_TOPIC"], + }, {"ParameterKey": "AssetsBucket", "ParameterValue": "testassetsbucket"}, {"ParameterKey": "CustomImage", "ParameterValue": os.environ["CUSTOMIMAGE"]}, {"ParameterKey": "ECRRepoName", "ParameterValue": "mlops-ecrrep"}, @@ -536,7 +558,10 @@ def _required_api_keys_model_monitor(monitoring_type, problem_type=None): return common_keys # ModelQuality specific keys - model_quality_keys = ["baseline_inference_attribute", "baseline_ground_truth_attribute"] + model_quality_keys = [ + "baseline_inference_attribute", + "baseline_ground_truth_attribute", + ] # common model related monitors common_model_keys = ["problem_type"] # add required keys based on problem type @@ -714,7 +739,9 @@ def _generate_names(endpoint_name, monitoring_type): @pytest.fixture def template_parameters_model_monitor(generate_names): def _template_parameters_model_monitor(event): - baseline_job_name, monitoring_schedule_name = generate_names("test-endpoint", "dataquality") + baseline_job_name, monitoring_schedule_name = generate_names( + "test-endpoint", "dataquality" + ) template_parameters = [ { "ParameterKey": "NotificationsSNSTopicArn", @@ -804,15 +831,22 @@ def _get_parameters_keys(parameters): @pytest.fixture def cf_client_params(api_byom_event, template_parameters_realtime_builtin): - template_parameters = template_parameters_realtime_builtin(api_byom_event("byom_realtime_builtin")) + template_parameters = template_parameters_realtime_builtin( + api_byom_event("byom_realtime_builtin") + ) cf_params = { "Capabilities": ["CAPABILITY_IAM"], "OnFailure": "DO_NOTHING", "Parameters": template_parameters, "RoleARN": "arn:aws:role:region:account:action", "StackName": "teststack-testmodel-BYOMPipelineReatimeBuiltIn", - "Tags": [{"Key": "stack_name", "Value": "teststack-testmodel-BYOMPipelineReatimeBuiltIn"}], - "TemplateURL": "https://testurl/blueprints/byom/byom_realtime_builtin_container.yaml", + "Tags": [ + { + "Key": "stack_name", + "Value": "teststack-testmodel-BYOMPipelineReatimeBuiltIn", + } + ], + "TemplateURL": "https://testurl/blueprints/byom_realtime_builtin_container.yaml", } return cf_params diff --git a/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py b/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py index dc22a7c..ed2325e 100644 --- a/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py +++ b/source/lambdas/pipeline_orchestration/tests/test_pipeline_orchestration.py @@ -100,7 +100,9 @@ def test_handler(): # event["path"] == "/provisionpipeline" and pipeline_type is model_crad operation - with patch("pipeline_orchestration.index.provision_model_card") as mock_provision_card: + with patch( + "pipeline_orchestration.index.provision_model_card" + ) as mock_provision_card: event = { "httpMethod": "POST", "path": "/provisionpipeline", @@ -109,7 +111,9 @@ def test_handler(): handler(event, {}) assert mock_provision_card.called is True # event["path"] == "/provisionpipeline" - with patch("pipeline_orchestration.index.provision_pipeline") as mock_provision_pipeline: + with patch( + "pipeline_orchestration.index.provision_pipeline" + ) as mock_provision_pipeline: event = { "httpMethod": "POST", "path": "/provisionpipeline", @@ -212,9 +216,14 @@ def test_clean_param(): def test_template_url(): - url = "https://" + os.environ["BLUEPRINT_BUCKET_URL"] + "/blueprints/byom" - TestCase().assertEqual(template_url("byom_batch_custom"), "blueprints/byom/byom_batch_pipeline.yaml") - TestCase().assertEqual(template_url("single_account_codepipeline"), f"{url}/single_account_codepipeline.yaml") + url = "https://" + os.environ["BLUEPRINT_BUCKET_URL"] + "/blueprints" + TestCase().assertEqual( + template_url("byom_batch_custom"), "blueprints/byom_batch_pipeline.yaml" + ) + TestCase().assertEqual( + template_url("single_account_codepipeline"), + f"{url}/single_account_codepipeline.yaml", + ) with pytest.raises(Exception): template_url("byom_not_supported") @@ -225,7 +234,9 @@ def test_provision_pipeline(api_image_builder_event, api_byom_event): expected_response = { "statusCode": 200, "isBase64Encoded": False, - "body": json.dumps({"message": "success: stack creation started", "pipeline_id": "1234"}), + "body": json.dumps( + {"message": "success: stack creation started", "pipeline_id": "1234"} + ), "headers": {"Content-Type": content_type}, } # The stubber will be called twice @@ -241,7 +252,10 @@ def test_provision_pipeline(api_image_builder_event, api_byom_event): testfile = tempfile.NamedTemporaryFile() s3_client.create_bucket(Bucket="testbucket") upload_file_to_s3( - testfile.name, "testbucket", "blueprints/byom/byom_realtime_inference_pipeline.yaml", s3_client + testfile.name, + "testbucket", + "blueprints/byom_realtime_inference_pipeline.yaml", + s3_client, ) s3_client.create_bucket(Bucket="testassetsbucket") response = provision_pipeline(event, client, s3_client) @@ -262,10 +276,14 @@ def test_download_file_from_s3(): testfile = tempfile.NamedTemporaryFile() s3_client.create_bucket(Bucket="assetsbucket") upload_file_to_s3(testfile.name, "assetsbucket", os.environ["TESTFILE"], s3_client) - download_file_from_s3("assetsbucket", os.environ["TESTFILE"], testfile.name, s3_client) + download_file_from_s3( + "assetsbucket", os.environ["TESTFILE"], testfile.name, s3_client + ) -def test_create_codepipeline_stack(cf_client_params, stack_name, stack_id, expected_update_response): +def test_create_codepipeline_stack( + cf_client_params, stack_name, stack_id, expected_update_response +): cf_client = botocore.session.get_session().create_client("cloudformation") not_image_stack = "teststack-testmodel-BYOMPipelineReatimeBuiltIn" stubber = Stubber(cf_client) @@ -308,7 +326,9 @@ def test_create_codepipeline_stack(cf_client_params, stack_name, stack_id, expec } ] } - stubber.add_response("describe_stacks", describe_cfn_response, describe_expected_params) + stubber.add_response( + "describe_stacks", describe_cfn_response, describe_expected_params + ) with stubber: response = create_codepipeline_stack( not_image_stack, @@ -323,12 +343,19 @@ def test_create_codepipeline_stack(cf_client_params, stack_name, stack_id, expec describe_expected_params["StackName"] = stack_name describe_cfn_response["Stacks"][0]["StackName"] = stack_name stubber.add_client_error("create_stack", service_message="already exists") - stubber.add_response("describe_stacks", describe_cfn_response, describe_expected_params) - stubber.add_client_error("update_stack", service_message="No updates are to be performed") + stubber.add_response( + "describe_stacks", describe_cfn_response, describe_expected_params + ) + stubber.add_client_error( + "update_stack", service_message="No updates are to be performed" + ) expected_response = expected_update_response with stubber: response = create_codepipeline_stack( - stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client + stack_name, + expected_params["TemplateURL"], + expected_params["Parameters"], + cf_client, ) assert response == expected_response @@ -347,16 +374,29 @@ def test_update_stack(cf_client_params, stack_name, stack_id, expected_update_re with stubber: response = update_stack( - stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client, stack_id + stack_name, + expected_params["TemplateURL"], + expected_params["Parameters"], + cf_client, + stack_id, ) - assert response == {**cfn_response, "message": f"Pipeline {stack_name} is being updated."} + assert response == { + **cfn_response, + "message": f"Pipeline {stack_name} is being updated.", + } # Test for no update error - stubber.add_client_error("update_stack", service_message="No updates are to be performed") + stubber.add_client_error( + "update_stack", service_message="No updates are to be performed" + ) expected_response = expected_update_response with stubber: response = update_stack( - stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client, stack_id + stack_name, + expected_params["TemplateURL"], + expected_params["Parameters"], + cf_client, + stack_id, ) assert response == expected_response @@ -364,11 +404,16 @@ def test_update_stack(cf_client_params, stack_name, stack_id, expected_update_re stubber.add_client_error("update_stack", service_message="Some Exception") with stubber: with pytest.raises(Exception): - update_stack(stack_name, expected_params["TemplateURL"], expected_params["Parameters"], cf_client, stack_id) + update_stack( + stack_name, + expected_params["TemplateURL"], + expected_params["Parameters"], + cf_client, + stack_id, + ) def test_pipeline_status(): - cfn_client = botocore.session.get_session().create_client("cloudformation") cp_client = botocore.session.get_session().create_client("codepipeline") @@ -450,7 +495,9 @@ def test_pipeline_status(): with cfn_stubber: with cp_stubber: - response = pipeline_status(event, cfn_client=cfn_client, cp_client=cp_client) + response = pipeline_status( + event, cfn_client=cfn_client, cp_client=cp_client + ) assert response == expected_response # test codepipeline has not been created yet @@ -472,11 +519,15 @@ def test_pipeline_status(): "body": "pipeline cloudformation stack has not provisioned the pipeline yet.", "headers": {"Content-Type": content_type}, } - cfn_stubber.add_response("list_stack_resources", no_cp_cfn_response, cfn_expected_params) + cfn_stubber.add_response( + "list_stack_resources", no_cp_cfn_response, cfn_expected_params + ) with cfn_stubber: with cp_stubber: - response = pipeline_status(event, cfn_client=cfn_client, cp_client=cp_client) + response = pipeline_status( + event, cfn_client=cfn_client, cp_client=cp_client + ) assert response == expected_response_no_cp @@ -496,7 +547,10 @@ def test_get_stack_name( ) # batch builtin pipeline batch_builtin = api_byom_event("byom_batch_builtin") - assert get_stack_name(batch_builtin) == f"mlops-pipeline-{batch_builtin['model_name']}-byompipelinebatchbuiltin" + assert ( + get_stack_name(batch_builtin) + == f"mlops-pipeline-{batch_builtin['model_name']}-byompipelinebatchbuiltin" + ) # data quality monitor pipeline assert ( @@ -537,7 +591,13 @@ def test_get_stack_name( @patch("solution_model_card.SolutionModelCardAPIs.update") @patch("solution_model_card.SolutionModelCardAPIs.create") def test_provision_model_card( - patched_create, patched_update, patched_describe, patched_delete, patched_export, patched_list, patched_session + patched_create, + patched_update, + patched_describe, + patched_delete, + patched_export, + patched_list, + patched_session, ): # assert the create APIs is called when pipeline_type=create_model_card event = dict(pipeline_type="create_model_card") @@ -615,21 +675,32 @@ def test_get_required_keys( expected_keys = required_api_keys_model_monitor("ModelQuality", "Regression") TestCase().assertCountEqual(expected_keys, returned_keys) # Required keys in model quality monitor, problem type BinaryClassification - returned_keys = get_required_keys("byom_model_quality_monitor", "No", "BinaryClassification") - expected_keys = required_api_keys_model_monitor("ModelQuality", "BinaryClassification") + returned_keys = get_required_keys( + "byom_model_quality_monitor", "No", "BinaryClassification" + ) + expected_keys = required_api_keys_model_monitor( + "ModelQuality", "BinaryClassification" + ) TestCase().assertCountEqual(expected_keys, returned_keys) # Required keys in model bias monitor, problem type BinaryClassification - returned_keys = get_required_keys("byom_model_bias_monitor", "No", "BinaryClassification") + returned_keys = get_required_keys( + "byom_model_bias_monitor", "No", "BinaryClassification" + ) expected_keys = required_api_keys_model_monitor("ModelBias", "BinaryClassification") TestCase().assertCountEqual(expected_keys, returned_keys) # Required keys in model expainability monitor, problem type Regression - returned_keys = get_required_keys("byom_model_explainability_monitor", "No", "Regression") + returned_keys = get_required_keys( + "byom_model_explainability_monitor", "No", "Regression" + ) expected_keys = required_api_keys_model_monitor("ModelExplainability", "Regression") TestCase().assertCountEqual(expected_keys, returned_keys) # test exception for unsupported problem type with pytest.raises(BadRequest) as error: get_required_keys("byom_model_quality_monitor", "No", "UnsupportedProblemType") - assert str(error.value) == "Bad request format. Unsupported problem_type in byom_model_quality_monitor pipeline" + assert ( + str(error.value) + == "Bad request format. Unsupported problem_type in byom_model_quality_monitor pipeline" + ) # Required keys in image builder returned_keys = get_required_keys("byom_image_builder", "No") expected_keys = required_api_image_builder @@ -649,9 +720,15 @@ def test_get_required_keys( def test_get_stage_param(api_byom_event): single_event = api_byom_event("byom_realtime_custom", False) - TestCase().assertEqual(get_stage_param(single_event, "data_capture_location", "None"), "bucket/datacapture") + TestCase().assertEqual( + get_stage_param(single_event, "data_capture_location", "None"), + "bucket/datacapture", + ) multi_event = api_byom_event("byom_realtime_custom", True) - TestCase().assertEqual(get_stage_param(multi_event, "data_capture_location", "dev"), "bucket/dev_datacapture") + TestCase().assertEqual( + get_stage_param(multi_event, "data_capture_location", "dev"), + "bucket/dev_datacapture", + ) def test_get_template_parameters( @@ -672,9 +749,14 @@ def test_get_template_parameters( ): single_event = api_byom_event("byom_realtime_custom", False) # realtime pipeline - TestCase().assertEqual(get_template_parameters(single_event, False), expected_params_realtime_custom()) + TestCase().assertEqual( + get_template_parameters(single_event, False), expected_params_realtime_custom() + ) # image builder pipeline - TestCase().assertEqual(get_template_parameters(api_image_builder_event, False), expected_image_builder_params) + TestCase().assertEqual( + get_template_parameters(api_image_builder_event, False), + expected_image_builder_params, + ) # batch pipeline TestCase().assertEqual( get_template_parameters(api_byom_event("byom_batch_custom", False), False), @@ -682,7 +764,11 @@ def test_get_template_parameters( ) # additional params used by Model Monitor asserts - common_params = [("AssetsBucket", "testassetsbucket"), ("KmsKeyArn", ""), ("BlueprintBucket", "testbucket")] + common_params = [ + ("AssetsBucket", "testassetsbucket"), + ("KmsKeyArn", ""), + ("BlueprintBucket", "testbucket"), + ] # data quality pipeline assert len(get_template_parameters(api_data_quality_event, False)) == len( [ @@ -716,50 +802,90 @@ def test_get_template_parameters( ) # autopilot templeate params single account - assert len(get_template_parameters(api_training_event("model_autopilot_training"), False)) == 16 + assert ( + len( + get_template_parameters( + api_training_event("model_autopilot_training"), False + ) + ) + == 16 + ) # autopilot templeate params multi account - assert len(get_template_parameters(api_training_event("model_autopilot_training"), True)) == 16 + assert ( + len( + get_template_parameters( + api_training_event("model_autopilot_training"), True + ) + ) + == 16 + ) with patch("lambda_helpers.sagemaker.image_uris.retrieve") as patched_uri: patched_uri.return_value = "algo-image" # training pipeline params - assert len(get_template_parameters(api_training_event("model_training_builtin"), True)) == 24 + assert ( + len( + get_template_parameters( + api_training_event("model_training_builtin"), True + ) + ) + == 24 + ) # hyperparameter tuning - assert len(get_template_parameters(api_training_event("model_tuner_builtin"), True)) == 26 + assert ( + len( + get_template_parameters(api_training_event("model_tuner_builtin"), True) + ) + == 26 + ) # test for exception with pytest.raises(BadRequest): get_template_parameters({"pipeline_type": "unsupported"}, False) -def test_get_common_realtime_batch_params(api_byom_event, expected_common_realtime_batch_params): +def test_get_common_realtime_batch_params( + api_byom_event, expected_common_realtime_batch_params +): realtime_event = api_byom_event("byom_realtime_custom", False) batch_event = api_byom_event("byom_batch_custom", False) realtime_event.update(batch_event) TestCase().assertEqual( - get_common_realtime_batch_params(realtime_event, "us-east-1", "None"), expected_common_realtime_batch_params + get_common_realtime_batch_params(realtime_event, "us-east-1", "None"), + expected_common_realtime_batch_params, ) -def test_get_realtime_specific_params(api_byom_event, expected_realtime_specific_params): +def test_get_realtime_specific_params( + api_byom_event, expected_realtime_specific_params +): # test with endpoint_name not provided realtime_event = api_byom_event("byom_realtime_builtin", False) - TestCase().assertEqual(get_realtime_specific_params(realtime_event, "None"), expected_realtime_specific_params()) + TestCase().assertEqual( + get_realtime_specific_params(realtime_event, "None"), + expected_realtime_specific_params(), + ) # test with endpoint_name provided realtime_event = api_byom_event("byom_realtime_builtin", False, True) TestCase().assertEqual( - get_realtime_specific_params(realtime_event, "None"), expected_realtime_specific_params(True) + get_realtime_specific_params(realtime_event, "None"), + expected_realtime_specific_params(True), ) # test with endpoint_name provided for multi-account realtime_event = api_byom_event("byom_realtime_builtin", False, True) - TestCase().assertEqual(get_realtime_specific_params(realtime_event, "dev"), expected_realtime_specific_params(True)) + TestCase().assertEqual( + get_realtime_specific_params(realtime_event, "dev"), + expected_realtime_specific_params(True), + ) def test_get_batch_specific_params(api_byom_event, expected_batch_specific_params): batch_event = api_byom_event("byom_batch_custom", False) - TestCase().assertEqual(get_batch_specific_params(batch_event, "None"), expected_batch_specific_params) + TestCase().assertEqual( + get_batch_specific_params(batch_event, "None"), expected_batch_specific_params + ) def test_get_built_in_model_monitor_container_uri(): @@ -791,7 +917,9 @@ def test_get_model_monitor_params( # The 156813124566 is one of the actual account ids for a public Model Monitor Image provided # by the SageMaker service. The reason is I need to provide a valid image URI because the SDK # has validation for the inputs - mocked_image_retrieve.return_value = "156813124566.dkr.ecr.us-east-1.amazonaws.com/sagemaker-model-monitor-analyzer" + mocked_image_retrieve.return_value = ( + "156813124566.dkr.ecr.us-east-1.amazonaws.com/sagemaker-model-monitor-analyzer" + ) # data quality monitor TestCase().assertEqual( len(get_model_monitor_params(api_data_quality_event, "us-east-1", "None")), @@ -799,33 +927,54 @@ def test_get_model_monitor_params( ) # model quality monitor TestCase().assertEqual( - len(get_model_monitor_params(api_model_quality_event, "us-east-1", "None", monitoring_type="ModelQuality")), + len( + get_model_monitor_params( + api_model_quality_event, + "us-east-1", + "None", + monitoring_type="ModelQuality", + ) + ), len(expected_model_quality_monitor_params), ) -def test_get_image_builder_params(api_image_builder_event, expected_image_builder_params): - TestCase().assertEqual(get_image_builder_params(api_image_builder_event), expected_image_builder_params) +def test_get_image_builder_params( + api_image_builder_event, expected_image_builder_params +): + TestCase().assertEqual( + get_image_builder_params(api_image_builder_event), expected_image_builder_params + ) def test_format_template_parameters( - expected_image_builder_params, expected_multi_account_params_format, expect_single_account_params_format + expected_image_builder_params, + expected_multi_account_params_format, + expect_single_account_params_format, ): TestCase().assertEqual( - format_template_parameters(expected_image_builder_params, "True"), expected_multi_account_params_format + format_template_parameters(expected_image_builder_params, "True"), + expected_multi_account_params_format, ) TestCase().assertEqual( - format_template_parameters(expected_image_builder_params, "False"), expect_single_account_params_format + format_template_parameters(expected_image_builder_params, "False"), + expect_single_account_params_format, ) @patch("lambda_helpers.sagemaker.image_uris.retrieve") def test_get_image_uri(mocked_sm, api_byom_event): custom_event = api_byom_event("byom_realtime_custom", False) - TestCase().assertEqual(get_image_uri("byom_realtime_custom", custom_event, "us-east-1"), "custom-image-uri") + TestCase().assertEqual( + get_image_uri("byom_realtime_custom", custom_event, "us-east-1"), + "custom-image-uri", + ) mocked_sm.return_value = "test-image-uri" builtin_event = api_byom_event("byom_realtime_builtin", False) - TestCase().assertEqual(get_image_uri("byom_realtime_builtin", builtin_event, "us-east-1"), "test-image-uri") + TestCase().assertEqual( + get_image_uri("byom_realtime_builtin", builtin_event, "us-east-1"), + "test-image-uri", + ) mocked_sm.assert_called_with( framework=builtin_event.get("model_framework"), region="us-east-1", @@ -860,11 +1009,23 @@ def test_create_template_zip_file( s3_client = boto3.client("s3", region_name="us-east-1") # multi account create_template_zip_file( - api_image_builder_event, "blueprint", "assets_bucket", "byom/template.yaml", "zipfile", "True", s3_client + api_image_builder_event, + "blueprint", + "assets_bucket", + "byom/template.yaml", + "zipfile", + "True", + s3_client, ) # single account create_template_zip_file( - api_image_builder_event, "blueprint", "assets_bucket", "byom/template.yaml", "zipfile", "False", s3_client + api_image_builder_event, + "blueprint", + "assets_bucket", + "byom/template.yaml", + "zipfile", + "False", + s3_client, ) @@ -879,7 +1040,11 @@ def test_get_codepipeline_params(): # multi account codepipeline TestCase().assertEqual( get_codepipeline_params( - "True", "byom_realtime_builtin", "stack_name", "template_zip_name", "template_file_name" + "True", + "byom_realtime_builtin", + "stack_name", + "template_zip_name", + "template_file_name", ), common_params + [ @@ -900,7 +1065,11 @@ def test_get_codepipeline_params(): # test training pipeline with multi-account TestCase().assertEqual( get_codepipeline_params( - "True", "model_training_builtin", "stack_name", "template_zip_name", "template_file_name" + "True", + "model_training_builtin", + "stack_name", + "template_zip_name", + "template_file_name", ), common_params + [("TemplateParamsName", "template_params.json")], ) @@ -908,7 +1077,11 @@ def test_get_codepipeline_params(): # single account codepipeline TestCase().assertEqual( get_codepipeline_params( - "False", "byom_realtime_builtin", "stack_name", "template_zip_name", "template_file_name" + "False", + "byom_realtime_builtin", + "stack_name", + "template_zip_name", + "template_file_name", ), common_params + [("TemplateParamsName", "template_params.json")], ) @@ -924,4 +1097,7 @@ def test_validate(api_byom_event): del bad_event["model_artifact_location"] with pytest.raises(BadRequest) as execinfo: validate(bad_event) - assert str(execinfo.value) == "Bad request. API body does not have the necessary parameter: model_artifact_location" + assert ( + str(execinfo.value) + == "Bad request. API body does not have the necessary parameter: model_artifact_location" + ) diff --git a/source/lambdas/solution_helper/requirements-test.txt b/source/lambdas/solution_helper/requirements-test.txt index 7b99f44..5b257b2 100644 --- a/source/lambdas/solution_helper/requirements-test.txt +++ b/source/lambdas/solution_helper/requirements-test.txt @@ -1,2 +1,3 @@ crhelper==2.0.6 -requests==2.28.1 \ No newline at end of file +urllib3==1.26.16 +requests==2.31.0 \ No newline at end of file diff --git a/source/lambdas/solution_helper/requirements.txt b/source/lambdas/solution_helper/requirements.txt index 7b99f44..5b257b2 100644 --- a/source/lambdas/solution_helper/requirements.txt +++ b/source/lambdas/solution_helper/requirements.txt @@ -1,2 +1,3 @@ crhelper==2.0.6 -requests==2.28.1 \ No newline at end of file +urllib3==1.26.16 +requests==2.31.0 \ No newline at end of file diff --git a/source/lib/blueprints/byom/lambdas/sagemaker_layer/requirements.txt b/source/lib/blueprints/byom/lambdas/sagemaker_layer/requirements.txt deleted file mode 100644 index c6a4b55..0000000 --- a/source/lib/blueprints/byom/lambdas/sagemaker_layer/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -botocore==1.29.75 -boto3==1.26.75 -awscli==1.27.75 -sagemaker==2.146.0 diff --git a/source/requirements-test.txt b/source/requirements-test.txt index 2b76da3..d670521 100644 --- a/source/requirements-test.txt +++ b/source/requirements-test.txt @@ -1,6 +1,6 @@ -sagemaker==2.146.0 -boto3==1.26.46 +sagemaker==2.165.0 +boto3==1.26.155 crhelper==2.0.6 pytest==7.2.0 -pytest-cov==4.0.0 +pytest-cov==4.1.0 moto[all]==4.1.3 \ No newline at end of file diff --git a/source/requirements.txt b/source/requirements.txt index e72a00e..c4c506e 100644 --- a/source/requirements.txt +++ b/source/requirements.txt @@ -1,34 +1,6 @@ -aws-cdk.assets==1.126.0 -aws-cdk.aws-apigateway==1.126.0 -aws-cdk.aws-cloudformation==1.126.0 -aws-cdk.aws-cloudwatch==1.126.0 -aws-cdk.aws-codebuild==1.126.0 -aws-cdk.aws-codecommit==1.126.0 -aws-cdk.aws-codedeploy==1.126.0 -aws-cdk.aws-codepipeline==1.126.0 -aws-cdk.aws-codepipeline-actions==1.126.0 -aws-cdk.core==1.126.0 -aws-cdk.aws-ecr==1.126.0 -aws-cdk.aws-ecr-assets==1.126.0 -aws-cdk.aws-events==1.126.0 -aws-cdk.aws-events-targets==1.126.0 -aws-cdk.aws-iam==1.126.0 -aws-cdk.aws-kms==1.126.0 -aws-cdk.aws-lambda==1.126.0 -aws-cdk.aws-lambda-event-sources==1.126.0 -aws-cdk.aws-logs==1.126.0 -aws-cdk.aws-s3==1.126.0 -aws-cdk.aws-s3-assets==1.126.0 -aws-cdk.aws-s3-deployment==1.126.0 -aws-cdk.aws-s3-notifications==1.126.0 -aws-cdk.aws-sagemaker==1.126.0 -aws-cdk.aws-sns==1.126.0 -aws-cdk.aws-sns-subscriptions==1.126.0 -aws-cdk.core==1.126.0 -aws-cdk.custom-resources==1.126.0 -aws-cdk.region-info==1.126.0 -aws-solutions-constructs.aws-apigateway-lambda==1.126.0 -aws-solutions-constructs.aws-lambda-sagemakerendpoint==1.126.0 -aws-solutions-constructs.core==1.126.0 -aws-cdk.cloudformation-include==1.126.0 -aws-cdk.aws-cloudformation==1.126.0 \ No newline at end of file +aws-cdk-lib==2.87.0 +constructs==10.1.272 +aws-solutions-constructs.aws-apigateway-lambda==2.41.0 +aws-solutions-constructs.aws-lambda-sagemakerendpoint==2.41.0 +aws-solutions-constructs.core==2.41.0 +aws-cdk.aws-servicecatalogappregistry-alpha==2.87.0.a.0 \ No newline at end of file