From 5dc5f0f6bb7e40c35b80822e7773c7ed29b95ad7 Mon Sep 17 00:00:00 2001 From: Alireza Assadzadeh Date: Wed, 22 Sep 2021 23:41:40 -0400 Subject: [PATCH] Update to version v1.0.0 --- .github/ISSUE_TEMPLATE/bug_report.md | 34 + .github/ISSUE_TEMPLATE/feature_request.md | 17 + .github/PULL_REQUEST_TEMPLATE.md | 5 + .gitignore | 63 + CHANGELOG.md | 9 + CODE_OF_CONDUCT.md | 4 +- CONTRIBUTING.md | 21 +- LICENSE => LICENSE.txt | 2 +- NOTICE | 1 - NOTICE.txt | 40 + README.md | 171 ++- deployment/run-unit-tests.sh | 71 ++ source/.coveragerc | 12 + source/aws_lambda/__init__.py | 12 + .../create_batch_inference_job/__init__.py | 12 + .../create_batch_inference_job/handler.py | 75 ++ source/aws_lambda/create_campaign/__init__.py | 12 + source/aws_lambda/create_campaign/handler.py | 64 ++ source/aws_lambda/create_dataset/__init__.py | 12 + source/aws_lambda/create_dataset/handler.py | 52 + .../create_dataset_group/__init__.py | 12 + .../create_dataset_group/handler.py | 54 + .../create_dataset_import_job/__init__.py | 12 + .../create_dataset_import_job/handler.py | 59 + .../create_event_tracker/__init__.py | 12 + .../create_event_tracker/handler.py | 48 + source/aws_lambda/create_filter/__init__.py | 12 + source/aws_lambda/create_filter/handler.py | 52 + source/aws_lambda/create_schema/__init__.py | 12 + source/aws_lambda/create_schema/handler.py | 44 + source/aws_lambda/create_solution/__init__.py | 12 + source/aws_lambda/create_solution/handler.py | 73 ++ .../create_solution_version/__init__.py | 12 + .../create_solution_version/handler.py | 60 + .../aws_lambda/create_timestamp/__init__.py | 12 + source/aws_lambda/create_timestamp/handler.py | 31 + .../get_next_scheduled_event/build.gradle | 72 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + .../get_next_scheduled_event/gradlew | 185 +++ .../get_next_scheduled_event/settings.gradle | 14 + .../HandleScheduleEvent.java | 59 + .../schedule_sfn_task/ScheduleEvent.java | 60 + .../schedule_sfn_task/ScheduleException.java | 20 + .../HandleScheduleEventTest.java | 58 + source/aws_lambda/s3_event/__init__.py | 12 + source/aws_lambda/s3_event/handler.py | 108 ++ source/aws_lambda/scheduler/__init__.py | 12 + source/aws_lambda/scheduler/handler.py | 54 + source/aws_lambda/shared/__init__.py | 12 + source/aws_lambda/shared/date_helpers.py | 35 + source/aws_lambda/shared/exceptions.py | 32 + .../aws_lambda/shared/personalize_service.py | 1022 +++++++++++++++++ source/aws_lambda/shared/resource/__init__.py | 53 + source/aws_lambda/shared/resource/base.py | 32 + .../shared/resource/batch_inference_job.py | 17 + source/aws_lambda/shared/resource/campaign.py | 17 + source/aws_lambda/shared/resource/dataset.py | 18 + .../shared/resource/dataset_group.py | 20 + .../shared/resource/dataset_import_job.py | 17 + .../shared/resource/event_tracker.py | 17 + source/aws_lambda/shared/resource/filter.py | 17 + source/aws_lambda/shared/resource/name.py | 81 ++ source/aws_lambda/shared/resource/schema.py | 17 + source/aws_lambda/shared/resource/solution.py | 19 + .../shared/resource/solution_version.py | 19 + source/aws_lambda/shared/s3.py | 81 ++ .../aws_lambda/shared/scheduler/__init__.py | 22 + source/aws_lambda/shared/scheduler/base.py | 282 +++++ .../aws_lambda/shared/scheduler/schedule.py | 109 ++ source/aws_lambda/shared/scheduler/task.py | 69 ++ .../shared/scheduler/task_resource.py | 38 + source/aws_lambda/shared/sfn_middleware.py | 289 +++++ .../aws_lambda/sns_notification/__init__.py | 12 + source/aws_lambda/sns_notification/handler.py | 127 ++ source/cdk_solution_helper_py/CHANGELOG.md | 10 + source/cdk_solution_helper_py/README.md | 247 ++++ .../helpers_cdk/aws_solutions/cdk/__init__.py | 33 + .../helpers_cdk/aws_solutions/cdk/aspects.py | 28 + .../aws_solutions/cdk/aws_lambda/__init__.py | 12 + .../cfn_custom_resources/__init__.py | 12 + .../resource_hash/__init__.py | 16 + .../resource_hash/hash.py | 84 ++ .../resource_hash/src/__init__.py | 12 + .../src/custom_resources/__init__.py | 12 + .../src/custom_resources/hash.py | 88 ++ .../src/custom_resources/requirements.txt | 1 + .../resource_name/__init__.py | 16 + .../resource_name/name.py | 90 ++ .../resource_name/src/__init__.py | 12 + .../src/custom_resources/__init__.py | 12 + .../src/custom_resources/name.py | 74 ++ .../src/custom_resources/requirements.txt | 1 + .../solutions_metrics/__init__.py | 16 + .../solutions_metrics/metrics.py | 87 ++ .../solutions_metrics/src/__init__.py | 12 + .../src/custom_resources/__init__.py | 12 + .../src/custom_resources/metrics.py | 81 ++ .../src/custom_resources/requirements.txt | 2 + .../cdk/aws_lambda/java/__init__.py | 12 + .../cdk/aws_lambda/java/bundling.py | 117 ++ .../cdk/aws_lambda/java/function.py | 117 ++ .../cdk/aws_lambda/python/__init__.py | 12 + .../cdk/aws_lambda/python/bundling.py | 225 ++++ .../cdk/aws_lambda/python/function.py | 189 +++ .../cdk/aws_lambda/python/layer.py | 79 ++ .../helpers_cdk/aws_solutions/cdk/cfn_nag.py | 58 + .../helpers_cdk/aws_solutions/cdk/context.py | 84 ++ .../aws_solutions/cdk/helpers/__init__.py | 14 + .../aws_solutions/cdk/helpers/copytree.py | 59 + .../aws_solutions/cdk/helpers/loader.py | 94 ++ .../aws_solutions/cdk/helpers/logger.py | 35 + .../aws_solutions/cdk/interfaces.py | 108 ++ .../helpers_cdk/aws_solutions/cdk/mappings.py | 54 + .../aws_solutions/cdk/scripts/__init__.py | 12 + .../cdk/scripts/build_s3_cdk_dist.py | 400 +++++++ .../helpers_cdk/aws_solutions/cdk/stack.py | 64 ++ .../aws_solutions/cdk/synthesizers.py | 317 +++++ .../aws_solutions/cdk/tools/__init__.py | 14 + .../aws_solutions/cdk/tools/cleaner.py | 77 ++ .../helpers_cdk/setup.py | 78 ++ .../aws_solutions/core/__init__.py | 24 + .../aws_solutions/core/config.py | 75 ++ .../aws_solutions/core/helpers.py | 100 ++ .../aws_solutions/core/logging.py | 58 + .../helpers_common/setup.py | 62 + .../requirements-dev.txt | 17 + source/images/solution-architecture.jpg | Bin 0 -> 303310 bytes source/infrastructure/__init__.py | 12 + source/infrastructure/cdk.json | 16 + source/infrastructure/deploy.py | 45 + source/infrastructure/personalize/__init__.py | 12 + .../personalize/aws_lambda/__init__.py | 12 + .../aws_lambda/functions/__init__.py | 33 + .../functions/create_batch_inference_job.py | 102 ++ .../aws_lambda/functions/create_campaign.py | 51 + .../aws_lambda/functions/create_dataset.py | 55 + .../functions/create_dataset_group.py | 164 +++ .../functions/create_dataset_import_job.py | 110 ++ .../functions/create_event_tracker.py | 59 + .../aws_lambda/functions/create_filter.py | 52 + .../functions/create_scheduled_task.py | 33 + .../aws_lambda/functions/create_schema.py | 50 + .../aws_lambda/functions/create_solution.py | 48 + .../functions/create_solution_version.py | 49 + .../aws_lambda/functions/create_timestamp.py | 29 + .../aws_lambda/functions/environment.py | 48 + .../functions/environment_variable.py | 31 + .../aws_lambda/functions/s3_event.py | 72 ++ .../aws_lambda/functions/sns_notification.py | 53 + .../aws_lambda/functions/solutionstep.py | 138 +++ .../personalize/aws_lambda/layers/__init__.py | 15 + .../layers/aws_lambda_powertools/__init__.py | 12 + .../layers/aws_lambda_powertools/layer.py | 33 + .../requirements/requirements.txt | 1 + .../layers/aws_solutions/__init__.py | 12 + .../aws_lambda/layers/aws_solutions/layer.py | 33 + .../requirements/requirements.txt | 5 + .../personalize/cloudwatch/__init__.py | 12 + .../personalize/cloudwatch/dashboard.py | 146 +++ .../infrastructure/personalize/s3/__init__.py | 15 + .../personalize/s3/access_logs_bucket.py | 18 + .../personalize/s3/data_bucket.py | 18 + source/infrastructure/personalize/s3/utils.py | 71 ++ .../personalize/scheduler/__init__.py | 14 + .../scheduler/aws_lambda/__init__.py | 12 + .../aws_lambda/functions/__init__.py | 25 + .../functions/create_scheduled_task.py | 75 ++ .../functions/delete_scheduled_task.py | 65 ++ .../functions/read_scheduled_task.py | 65 ++ .../functions/update_scheduled_task.py | 75 ++ .../personalize/scheduler/base.py | 399 +++++++ .../personalize/sns/__init__.py | 12 + .../personalize/sns/notifications.py | 84 ++ source/infrastructure/personalize/stack.py | 415 +++++++ .../personalize/step_functions/__init__.py | 12 + .../batch_inference_jobs_fragment.py | 178 +++ .../step_functions/dataset_import_fragment.py | 123 ++ .../dataset_imports_fragment.py | 59 + .../step_functions/event_tracker_fragment.py | 67 ++ .../step_functions/failure_fragment.py | 57 + .../step_functions/filter_fragment.py | 88 ++ .../personalization_fragment.py | 81 ++ .../scheduled_dataset_import.py | 85 ++ .../scheduled_solution_maintenance.py | 99 ++ .../step_functions/scheduler_fragment.py | 91 ++ .../personalize/step_functions/schedules.py | 21 + .../step_functions/solution_fragment.py | 313 +++++ source/infrastructure/setup.py | 49 + source/pytest.ini | 15 + source/requirements-dev.txt | 21 + source/tests/__init__.py | 12 + .../test_batch_inference_job_handler.py | 21 + .../test_create_campaign_handler.py | 21 + .../create_dataset/test_dataset_handler.py | 21 + .../test_dataset_group_handler.py | 21 + .../test_dataset_import_job_handler.py | 21 + .../test_create_event_tracker_handler.py | 21 + .../test_create_filter_handler.py | 21 + .../create_schema/create_schema_handler.py | 21 + .../test_create_solution_handler.py | 21 + .../test_create_solution_version_handler.py | 21 + .../s3_event/test_s3_event_handler.py | 183 +++ .../sns_notification/test_sns_notification.py | 108 ++ .../aws_lambda/test_personalize_service.py | 492 ++++++++ .../tests/aws_lambda/test_sfn_middleware.py | 206 ++++ source/tests/cdk_solution_helper/__init__.py | 12 + .../resource_hash/test_resource_name.py | 74 ++ .../resource_hash/test_resource_name_cdk.py | 44 + .../resource_name/test_resource_hash.py | 71 ++ .../resource_name/test_resource_hash_cdk.py | 44 + .../solution_metrics/test_metrics_cdk.py | 49 + .../solution_metrics/test_metrics_resource.py | 144 +++ .../java/fixtures/java_sample/build.gradle | 49 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + .../java/fixtures/java_sample/gradlew | 185 +++ .../java/fixtures/java_sample/settings.gradle | 2 + .../src/main/java/example/Handler.java | 14 + .../src/main/java/example/UserData.java | 30 + .../src/test/java/example/HandlerTest.java | 23 + .../aws_lambda/java/test_java_function.py | 60 + .../aws_lambda/python/fixtures/Pipfile | 2 + .../python/fixtures/hash_fixture/a/z.txt | 1 + .../python/fixtures/hash_fixture/c.txt | 1 + .../python/fixtures/hash_fixture/z/a.txt | 1 + .../lambda/package/minimal/__init__.py | 16 + .../python/fixtures/lambda/package/setup.py | 23 + .../aws_lambda/python/fixtures/pyproject.toml | 15 + .../python/fixtures/requirements.txt | 1 + .../aws_lambda/python/test_function.py | 172 +++ .../aws_lambda/python/test_layer_version.py | 77 ++ .../helpers/test_load_cdk_app.py | 136 +++ .../helpers/test_logger.py | 33 + .../tests/cdk_solution_helper/test_aspects.py | 56 + .../test_build_s3_cdk_dist.py | 162 +++ .../cdk_solution_helper/test_cdk_context.py | 117 ++ .../test_cdk_interfaces.py | 63 + .../test_cfn_nag_suppressions.py | 46 + .../tests/cdk_solution_helper/test_helpers.py | 70 ++ .../cdk_solution_helper/test_interfaces.py | 135 +++ .../tests/cdk_solution_helper/test_logging.py | 58 + .../cdk_solution_helper/test_mappings.py | 38 + .../test_solution_config.py | 142 +++ .../tests/cdk_solution_helper/test_stack.py | 115 ++ .../cdk_solution_helper/test_synthesizers.py | 76 ++ .../cdk_solution_helper/tools/test_cleaner.py | 112 ++ source/tests/conftest.py | 125 ++ .../tests/fixtures/config/sample_config.json | 174 +++ source/tests/fixtures/config/step_1.json | 5 + source/tests/fixtures/config/step_2.json | 124 ++ source/tests/fixtures/config/step_4.json | 130 +++ source/tests/fixtures/config/users.csv | 25 + source/tests/test_deploy.py | 42 + source/tests/test_personalize_stack.py | 30 + source/tests/test_resources.py | 59 + source/tests/test_scheduler.py | 244 ++++ 257 files changed, 16750 insertions(+), 22 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 CHANGELOG.md rename LICENSE => LICENSE.txt (99%) delete mode 100644 NOTICE create mode 100644 NOTICE.txt create mode 100644 deployment/run-unit-tests.sh create mode 100644 source/.coveragerc create mode 100644 source/aws_lambda/__init__.py create mode 100644 source/aws_lambda/create_batch_inference_job/__init__.py create mode 100644 source/aws_lambda/create_batch_inference_job/handler.py create mode 100644 source/aws_lambda/create_campaign/__init__.py create mode 100644 source/aws_lambda/create_campaign/handler.py create mode 100644 source/aws_lambda/create_dataset/__init__.py create mode 100644 source/aws_lambda/create_dataset/handler.py create mode 100644 source/aws_lambda/create_dataset_group/__init__.py create mode 100644 source/aws_lambda/create_dataset_group/handler.py create mode 100644 source/aws_lambda/create_dataset_import_job/__init__.py create mode 100644 source/aws_lambda/create_dataset_import_job/handler.py create mode 100644 source/aws_lambda/create_event_tracker/__init__.py create mode 100644 source/aws_lambda/create_event_tracker/handler.py create mode 100644 source/aws_lambda/create_filter/__init__.py create mode 100644 source/aws_lambda/create_filter/handler.py create mode 100644 source/aws_lambda/create_schema/__init__.py create mode 100644 source/aws_lambda/create_schema/handler.py create mode 100644 source/aws_lambda/create_solution/__init__.py create mode 100644 source/aws_lambda/create_solution/handler.py create mode 100644 source/aws_lambda/create_solution_version/__init__.py create mode 100644 source/aws_lambda/create_solution_version/handler.py create mode 100644 source/aws_lambda/create_timestamp/__init__.py create mode 100644 source/aws_lambda/create_timestamp/handler.py create mode 100644 source/aws_lambda/get_next_scheduled_event/build.gradle create mode 100644 source/aws_lambda/get_next_scheduled_event/gradle/wrapper/gradle-wrapper.jar create mode 100644 source/aws_lambda/get_next_scheduled_event/gradle/wrapper/gradle-wrapper.properties create mode 100755 source/aws_lambda/get_next_scheduled_event/gradlew create mode 100644 source/aws_lambda/get_next_scheduled_event/settings.gradle create mode 100644 source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEvent.java create mode 100644 source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleEvent.java create mode 100644 source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleException.java create mode 100644 source/aws_lambda/get_next_scheduled_event/src/test/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEventTest.java create mode 100644 source/aws_lambda/s3_event/__init__.py create mode 100644 source/aws_lambda/s3_event/handler.py create mode 100644 source/aws_lambda/scheduler/__init__.py create mode 100644 source/aws_lambda/scheduler/handler.py create mode 100644 source/aws_lambda/shared/__init__.py create mode 100644 source/aws_lambda/shared/date_helpers.py create mode 100644 source/aws_lambda/shared/exceptions.py create mode 100644 source/aws_lambda/shared/personalize_service.py create mode 100644 source/aws_lambda/shared/resource/__init__.py create mode 100644 source/aws_lambda/shared/resource/base.py create mode 100644 source/aws_lambda/shared/resource/batch_inference_job.py create mode 100644 source/aws_lambda/shared/resource/campaign.py create mode 100644 source/aws_lambda/shared/resource/dataset.py create mode 100644 source/aws_lambda/shared/resource/dataset_group.py create mode 100644 source/aws_lambda/shared/resource/dataset_import_job.py create mode 100644 source/aws_lambda/shared/resource/event_tracker.py create mode 100644 source/aws_lambda/shared/resource/filter.py create mode 100644 source/aws_lambda/shared/resource/name.py create mode 100644 source/aws_lambda/shared/resource/schema.py create mode 100644 source/aws_lambda/shared/resource/solution.py create mode 100644 source/aws_lambda/shared/resource/solution_version.py create mode 100644 source/aws_lambda/shared/s3.py create mode 100644 source/aws_lambda/shared/scheduler/__init__.py create mode 100644 source/aws_lambda/shared/scheduler/base.py create mode 100644 source/aws_lambda/shared/scheduler/schedule.py create mode 100644 source/aws_lambda/shared/scheduler/task.py create mode 100644 source/aws_lambda/shared/scheduler/task_resource.py create mode 100644 source/aws_lambda/shared/sfn_middleware.py create mode 100644 source/aws_lambda/sns_notification/__init__.py create mode 100644 source/aws_lambda/sns_notification/handler.py create mode 100644 source/cdk_solution_helper_py/CHANGELOG.md create mode 100644 source/cdk_solution_helper_py/README.md create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aspects.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/hash.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/hash.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/requirements.txt create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/name.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/name.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/requirements.txt create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/metrics.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/metrics.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/requirements.txt create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/bundling.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/function.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/bundling.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/function.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/layer.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/cfn_nag.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/context.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/copytree.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/loader.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/logger.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/interfaces.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/mappings.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/build_s3_cdk_dist.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/stack.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/synthesizers.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/cleaner.py create mode 100644 source/cdk_solution_helper_py/helpers_cdk/setup.py create mode 100644 source/cdk_solution_helper_py/helpers_common/aws_solutions/core/__init__.py create mode 100644 source/cdk_solution_helper_py/helpers_common/aws_solutions/core/config.py create mode 100644 source/cdk_solution_helper_py/helpers_common/aws_solutions/core/helpers.py create mode 100644 source/cdk_solution_helper_py/helpers_common/aws_solutions/core/logging.py create mode 100644 source/cdk_solution_helper_py/helpers_common/setup.py create mode 100644 source/cdk_solution_helper_py/requirements-dev.txt create mode 100644 source/images/solution-architecture.jpg create mode 100644 source/infrastructure/__init__.py create mode 100644 source/infrastructure/cdk.json create mode 100644 source/infrastructure/deploy.py create mode 100644 source/infrastructure/personalize/__init__.py create mode 100644 source/infrastructure/personalize/aws_lambda/__init__.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/__init__.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_batch_inference_job.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_campaign.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_dataset.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_dataset_group.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_dataset_import_job.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_event_tracker.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_filter.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_scheduled_task.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_schema.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_solution.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_solution_version.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/create_timestamp.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/environment.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/environment_variable.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/s3_event.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/sns_notification.py create mode 100644 source/infrastructure/personalize/aws_lambda/functions/solutionstep.py create mode 100644 source/infrastructure/personalize/aws_lambda/layers/__init__.py create mode 100644 source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/__init__.py create mode 100644 source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/layer.py create mode 100644 source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/requirements/requirements.txt create mode 100644 source/infrastructure/personalize/aws_lambda/layers/aws_solutions/__init__.py create mode 100644 source/infrastructure/personalize/aws_lambda/layers/aws_solutions/layer.py create mode 100644 source/infrastructure/personalize/aws_lambda/layers/aws_solutions/requirements/requirements.txt create mode 100644 source/infrastructure/personalize/cloudwatch/__init__.py create mode 100644 source/infrastructure/personalize/cloudwatch/dashboard.py create mode 100644 source/infrastructure/personalize/s3/__init__.py create mode 100644 source/infrastructure/personalize/s3/access_logs_bucket.py create mode 100644 source/infrastructure/personalize/s3/data_bucket.py create mode 100644 source/infrastructure/personalize/s3/utils.py create mode 100644 source/infrastructure/personalize/scheduler/__init__.py create mode 100644 source/infrastructure/personalize/scheduler/aws_lambda/__init__.py create mode 100644 source/infrastructure/personalize/scheduler/aws_lambda/functions/__init__.py create mode 100644 source/infrastructure/personalize/scheduler/aws_lambda/functions/create_scheduled_task.py create mode 100644 source/infrastructure/personalize/scheduler/aws_lambda/functions/delete_scheduled_task.py create mode 100644 source/infrastructure/personalize/scheduler/aws_lambda/functions/read_scheduled_task.py create mode 100644 source/infrastructure/personalize/scheduler/aws_lambda/functions/update_scheduled_task.py create mode 100644 source/infrastructure/personalize/scheduler/base.py create mode 100644 source/infrastructure/personalize/sns/__init__.py create mode 100644 source/infrastructure/personalize/sns/notifications.py create mode 100644 source/infrastructure/personalize/stack.py create mode 100644 source/infrastructure/personalize/step_functions/__init__.py create mode 100644 source/infrastructure/personalize/step_functions/batch_inference_jobs_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/dataset_import_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/dataset_imports_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/event_tracker_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/failure_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/filter_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/personalization_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/scheduled_dataset_import.py create mode 100644 source/infrastructure/personalize/step_functions/scheduled_solution_maintenance.py create mode 100644 source/infrastructure/personalize/step_functions/scheduler_fragment.py create mode 100644 source/infrastructure/personalize/step_functions/schedules.py create mode 100644 source/infrastructure/personalize/step_functions/solution_fragment.py create mode 100644 source/infrastructure/setup.py create mode 100644 source/pytest.ini create mode 100644 source/requirements-dev.txt create mode 100644 source/tests/__init__.py create mode 100644 source/tests/aws_lambda/create_batch_inference_job/test_batch_inference_job_handler.py create mode 100644 source/tests/aws_lambda/create_campaign/test_create_campaign_handler.py create mode 100644 source/tests/aws_lambda/create_dataset/test_dataset_handler.py create mode 100644 source/tests/aws_lambda/create_dataset_group/test_dataset_group_handler.py create mode 100644 source/tests/aws_lambda/create_dataset_import_job/test_dataset_import_job_handler.py create mode 100644 source/tests/aws_lambda/create_event_tracker/test_create_event_tracker_handler.py create mode 100644 source/tests/aws_lambda/create_filter/test_create_filter_handler.py create mode 100644 source/tests/aws_lambda/create_schema/create_schema_handler.py create mode 100644 source/tests/aws_lambda/create_solution/test_create_solution_handler.py create mode 100644 source/tests/aws_lambda/create_solution_version/test_create_solution_version_handler.py create mode 100644 source/tests/aws_lambda/s3_event/test_s3_event_handler.py create mode 100644 source/tests/aws_lambda/sns_notification/test_sns_notification.py create mode 100644 source/tests/aws_lambda/test_personalize_service.py create mode 100644 source/tests/aws_lambda/test_sfn_middleware.py create mode 100644 source/tests/cdk_solution_helper/__init__.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name_cdk.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash_cdk.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_cdk.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_resource.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/build.gradle create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradle/wrapper/gradle-wrapper.jar create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradle/wrapper/gradle-wrapper.properties create mode 100755 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradlew create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/settings.gradle create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/Handler.java create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/UserData.java create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/test/java/example/HandlerTest.java create mode 100644 source/tests/cdk_solution_helper/aws_lambda/java/test_java_function.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/Pipfile create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/a/z.txt create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/c.txt create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/z/a.txt create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/minimal/__init__.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/setup.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/pyproject.toml create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/fixtures/requirements.txt create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/test_function.py create mode 100644 source/tests/cdk_solution_helper/aws_lambda/python/test_layer_version.py create mode 100644 source/tests/cdk_solution_helper/helpers/test_load_cdk_app.py create mode 100644 source/tests/cdk_solution_helper/helpers/test_logger.py create mode 100644 source/tests/cdk_solution_helper/test_aspects.py create mode 100644 source/tests/cdk_solution_helper/test_build_s3_cdk_dist.py create mode 100644 source/tests/cdk_solution_helper/test_cdk_context.py create mode 100644 source/tests/cdk_solution_helper/test_cdk_interfaces.py create mode 100644 source/tests/cdk_solution_helper/test_cfn_nag_suppressions.py create mode 100644 source/tests/cdk_solution_helper/test_helpers.py create mode 100644 source/tests/cdk_solution_helper/test_interfaces.py create mode 100644 source/tests/cdk_solution_helper/test_logging.py create mode 100644 source/tests/cdk_solution_helper/test_mappings.py create mode 100644 source/tests/cdk_solution_helper/test_solution_config.py create mode 100644 source/tests/cdk_solution_helper/test_stack.py create mode 100644 source/tests/cdk_solution_helper/test_synthesizers.py create mode 100644 source/tests/cdk_solution_helper/tools/test_cleaner.py create mode 100644 source/tests/conftest.py create mode 100644 source/tests/fixtures/config/sample_config.json create mode 100644 source/tests/fixtures/config/step_1.json create mode 100644 source/tests/fixtures/config/step_2.json create mode 100644 source/tests/fixtures/config/step_4.json create mode 100644 source/tests/fixtures/config/users.csv create mode 100644 source/tests/test_deploy.py create mode 100644 source/tests/test_personalize_stack.py create mode 100644 source/tests/test_resources.py create mode 100644 source/tests/test_scheduler.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2083e17 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Please complete the following information about the solution:** +- [ ] Version: [e.g. v1.0.0] + +To get the version of the solution, you can look at the description of the created CloudFormation stack. For example, "(SO0170) Maintaining Personalized Experiences with Machine Learning [...]". + +- [ ] Region: [e.g. us-east-1] +- [ ] Was the solution modified from the version published on this repository? +- [ ] If the answer to the previous question was yes, are the changes available on GitHub? +- [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for the sevices this solution uses? +- [ ] Were there any errors in the CloudWatch Logs? + +**Screenshots** +If applicable, add screenshots to help explain your problem (please **DO NOT include sensitive information**). + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d3d209f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this solution +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the feature you'd like** +A clear and concise description of what you want to happen. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..de50e4d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +*Issue #, if available:* + +*Description of changes:* + +By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..441f5ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Modified based on https://www.gitignore.io/api/visualstudiocode,python + +# compiled output +**/global-s3-assets +**/regional-s3-assets +**/build-s3-assets +**/open-source +**/tmp + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Python Distribution / packaging +*.egg-info/ +*.egg + +# Python Virtual Environments +**/venv* +**/.venv* +.python-version + +## Python Testing +**/.pytest_cache +**/.coverage +**/coverage-reports/ + +# linting, scanning configurations, sonarqube +.scannerwork/ + +### VisualStudioCode ### +.vscode/* + +### IntelliJ/ PyCharm ### +**/.idea/* + +# System Files +**/.DS_Store + +# CDK +**/cdk.out + +# Glue +.glue/* + +# Generated test assets +source/infrastructure/tests/assets/* +!source/infrastructure/tests/assets/.keep +source/aws_lambda/get_next_scheduled_event/.gradle +source/aws_lambda/get_next_scheduled_event/build +source/aws_lambda/get_next_scheduled_event/.idea + +# gradle build files +**/.gradle/* + +# java build files +**/java/**/build + +# python build files +source/cdk_solution_helper_py/helpers_cdk/build/* +source/cdk_solution_helper_py/helpers_common/build/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..214a545 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log +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). + +## [1.0.0] - 2021-09-23 +### Added +- All files, initial version diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5b627cf..3b64466 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,4 +1,4 @@ ## Code of Conduct -This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). -For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact +This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). +For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact opensource-codeofconduct@amazon.com with any additional questions or comments. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4b6a1c..87f8a6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ information to effectively respond to your bug report or contribution. We welcome you to use the GitHub issue tracker to report bugs or suggest features. -When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already +When filing an issue, please check [existing open](https://github.com/aws-solutions/maintaining-personalized-experiences-with-machine-learning/issues), or [recently closed](https://github.com/aws-solutions/maintaining-personalized-experiences-with-machine-learning/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: * A reproducible test case or series of steps @@ -23,7 +23,7 @@ reported the issue. Please try to include as much information as you can. Detail ## Contributing via Pull Requests Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: -1. You are working against the latest source on the *main* branch. +1. You are working against the latest source on the *master* branch. 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. @@ -31,17 +31,18 @@ To send us a pull request, please: 1. Fork the repository. 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. -3. Ensure local tests pass. -4. Commit to your fork using clear commit messages. -5. Send us a pull request, answering any default questions in the pull request interface. -6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. +3. Ensure all build processes execute successfully (see README.md for additional guidance). +4. Ensure all unit, integration, and/or snapshot tests pass, as applicable. +5. Commit to your fork using clear commit messages. +6. Send us a pull request, answering any default questions in the pull request interface. +7. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). ## Finding contributions to work on -Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. +Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-solutions/maintaining-personalized-experiences-with-machine-learning/labels/help%20wanted) issues is a great place to start. ## Code of Conduct @@ -51,9 +52,11 @@ opensource-codeofconduct@amazon.com with any additional questions or comments. ## Security issue notifications -If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. +If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public GitHub issue. ## Licensing -See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. +See the [LICENSE](https://github.com/aws-solutions/maintaining-personalized-experiences-with-machine-learning/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. + +We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. diff --git a/LICENSE b/LICENSE.txt similarity index 99% rename from LICENSE rename to LICENSE.txt index 67db858..19dc35b 100644 --- a/LICENSE +++ b/LICENSE.txt @@ -172,4 +172,4 @@ of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + of your accepting any such warranty or additional liability. \ No newline at end of file diff --git a/NOTICE b/NOTICE deleted file mode 100644 index 616fc58..0000000 --- a/NOTICE +++ /dev/null @@ -1 +0,0 @@ -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..4498f16 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,40 @@ +Maintaining Personalized Experiences with Machine Learning + +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/ +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. + +********************** +THIRD PARTY COMPONENTS +********************** +This software includes third party software subject to the following copyrights: + +Apache Avro under the Apache License 2.0 +AWS Lambda Java Support Libraries under the Apache License Version 2.0 +AWS Lambda Powertools for Python under the MIT No Attribution license +AWS SDK under the Apache License Version 2.0 +boto3 under the Apache License Version 2.0 +black under the Massachusetts Institute of Technology (MIT) license +click under the BSD 3-Clause license +coverage under the Apache License Version 2.0 +crhelper under the Apache License Version 2.0 +cronex under the Massachusetts Institute of Technology (MIT) license +docker-py under the Apache License Version 2.0 +Gradle under the Apache License Version 2.0 +jmespath under the Apache License Version 2.0 +junit under the Eclipse Public License Version 2.0 +moto 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 +pytest-env under the Massachusetts Institute of Technology (MIT) license +PyYAML under the Massachusetts Institute of Technology (MIT) license +requests under the Apache License Version 2.0 +requests-mock under the Apache License Version 2.0 +tenacity under the Apache License Version 2.0 +quartz-scheduler under the Apache License Version 2.0 + +The Apache License Version Version 2.0 is included in LICENSE.txt. \ No newline at end of file diff --git a/README.md b/README.md index 847260c..20ae8c8 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,170 @@ -## My Project +# Maintaining Personalized Experiences with Machine Learning +The Maintaining Personalized Experiences with Machine Learning solution provides a mechanism to automate much of the +workflow around Amazon Personalize. This includes dataset group creation, dataset creation and import, solution +creation, solution version creation, campaign creation and batch inference job creation -TODO: Fill this README out! +Scheduled rules can be configured for setting up import jobs, solution version retraining (with campaign update) and +batch inference job creation. -Be sure to: +## Table of Contents -* Change the title in this README -* Edit your repository description on GitHub +- [Architecture for the AWS MLOps for Amazon Personalize Solution](#architecture) +- [AWS CDK Constructs](#aws-cdk-constructs) +- [Deployment](#deployment) +- [Creating a custom build](#creating-a-custom-build) +- [Collection of operational metrics](#collection-of-operational-metrics) -## Security +## Architecture -See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. +The following describes the architecture of the solution -## License +![architecture](source/images/solution-architecture.jpg) -This project is licensed under the Apache-2.0 License. +The AWS CloudFormation template deploys the resources required to automate your Amazon Personalize usage and deployments. +The template includes the following components: +1. An Amazon S3 bucket used to store personalization data and configuration files. +2. An AWS Lambda function triggered when new/ updated personalization configuration is uploaded to the personalization data bucket. +3. An AWS Stepfunctions workflow to manage all of the resources of an Amazon Personalize dataset group (including datasets, schemas, event tracker, filters, solutions, campaigns, and batch inference jobs). +4. CloudWatch metrics for Amazon Personalize for each new trained solution version are added to help you evaluate the performance of a model over time. +5. An Amazon Simple Notification Service (SNS) topic and subscription to notify an administrator when the maintenance workflow has completed via email. +6. DynamoDB is used to track the scheduled events configured for Amazon Personalize to fully or partially retrain solutions, (re) import datasets and perform batch inference jobs. +7. An AWS Stepfunctions workflow is used to track the current running scheduled events, and invokes step functions to perform solution maintenance (creating new solution versions, updating campaigns), import updated datasets, and perform batch inference. +8. A set of maintenance AWS Stepfunctions workflows are provided to: + 1. Create new dataset import jobs on schedule + 2. Perform solution FULL retraining on schedule (and update associated campaigns) + 3. Perform solution UPDATE retraining on schedule (and update associated campaigns) + 4. Create batch inference jobs + + +**Note**: From v1.0.0, AWS CloudFormation template resources are created by the [AWS CDK](https://aws.amazon.com/cdk/) +and [AWS Solutions Constructs](https://aws.amazon.com/solutions/constructs/). + +### AWS CDK Constructs + +[AWS CDK Solutions Constructs](https://aws.amazon.com/solutions/constructs/) make it easier to consistently create +well-architected applications. All AWS Solutions Constructs are reviewed by AWS and use best practices established by +the AWS Well-Architected Framework. This solution uses the following AWS CDK Solutions Constructs: + +- [aws-lambda-sns](https://docs.aws.amazon.com/solutions/latest/constructs/aws-lambda-sns.html) + +## Deployment + +You can launch this solution with one click from [AWS Solutions Implementations](https://aws.amazon.com/solutions/implementations/maintaining-personalized-experiences-with-ml). + +To customize the solution, or to contribute to the solution, follow the steps below: + +## Creating a custom build +To customize the solution, follow the steps below: + +### Prerequisites +The following procedures assumes that all the OS-level configuration has been completed. They are: + +* [AWS Command Line Interface](https://aws.amazon.com/cli/) +* [Python](https://www.python.org/) 3.7 or newer +* [Node.js](https://nodejs.org/en/) 16.x or newer +* [AWS CDK](https://aws.amazon.com/cdk/) 1.95.2 or newer +* [Amazon Corretto OpenJDK](https://docs.aws.amazon.com/corretto/) 11 + +> **Please ensure you test the templates before updating any production deployments.** + +### 1. Download or clone this repo +``` +git clone https://github.com/aws-solutions/maintaining-personalized-experiences-with-machine-learning +``` + +### 2. Create a Python virtual environment for development +```bash +python -m virtualenv .venv +source ./.venv/bin/activate +cd ./source +pip install -r requirements-dev.txt +``` + +### 2. After introducing changes, run the unit tests to make sure the customizations don't break existing functionality +```bash +pytest --cov +``` + +### 3. Build the solution for deployment + +#### Using AWS CDK (recommended) +Packaging and deploying the solution with the AWS CDK allows for the most flexibility in development +```bash +cd ./source/infrastructure + +# set environment variables required by the solution +export BUCKET_NAME="my-bucket-name" + +# bootstrap CDK (required once - deploys a CDK bootstrap CloudFormation stack for assets) +cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess + +# build the solution +cdk synth + +# build and deploy the solution +cdk deploy +``` + +#### Using the solution build tools +It is highly recommended to use the AWS CDK to deploy this solution (using the instructions above). While CDK is used to +develop the solution, to package the solution for release as a CloudFormation template, use the `build-s3-cdk-dist` +build tool: + +```bash +cd ./deployment + +export DIST_BUCKET_PREFIX=my-bucket-name +export SOLUTION_NAME=my-solution-name +export VERSION=my-version +export REGION_NAME=my-region + +build-s3-cdk-dist \ + --source-bucket-name DIST_BUCKET_PREFIX \ + --solution-name SOLUTION_NAME \ + --version_code VERSION \ + --cdk-app-path ../source/infrastructure/deploy.py \ + --cdk-app-entrypoint deploy:build_app \ + --region REGION_NAME \ + --sync +``` + +**Parameter Details** +- `$DIST_BUCKET_PREFIX` - The S3 bucket name prefix. A randomized value is recommended. You will need to create an + S3 bucket where the name is `-`. The solution's CloudFormation template will expect the + source code to be located in the bucket matching that name. +- `$SOLUTION_NAME` - The name of This solution (example: personalize-solution-customization) +- `$VERSION` - The version number to use (example: v1.0.0) +- `$REGION_NAME` - The region name to use (example: us-east-1) + +This will result in all global assets being pushed to the `DIST_BUCKET_PREFIX`, and all regional assets being pushed to +`DIST_BUCKET_PREFIX-`. If your `REGION_NAME` is us-east-1, and the `DIST_BUCKET_PREFIX` is +`my-bucket-name`, ensure that both `my-bucket-name` and `my-bucket-name-us-east-1` exist and are owned by you. + +After running the command, you can deploy the template: + +* Get the link of the `SOLUTION_NAME.template` uploaded to your Amazon S3 bucket +* Deploy the solution to your account by launching a new AWS CloudFormation stack using the link of the template above. + +> **Note:** `build-s3-cdk-dist` will use your current configured `AWS_REGION` and `AWS_PROFILE`. To set your defaults, +> install the [AWS Command Line Interface](https://aws.amazon.com/cli/) and run `aws configure`. + +## Collection of operational metrics +This solution collects anonymous operational metrics to help AWS improve the quality of features of the solution. +For more information, including how to disable this capability, please see the [implementation guide](https://aws.amazon.com/solutions/implementations/maintaining-personalized-experiences-with-ml). + +*** + +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. \ No newline at end of file diff --git a/deployment/run-unit-tests.sh b/deployment/run-unit-tests.sh new file mode 100644 index 0000000..d0de7aa --- /dev/null +++ b/deployment/run-unit-tests.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# +# 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. +# + +# +# This assumes all of the OS-level configuration has been completed and git repo has already been cloned +# +# This script should be run from the repo's deployment directory +# cd deployment +# ./run-unit-tests.sh +# + +[ "$DEBUG" == 'true' ] && set -x +set -e + +# Get reference for all important folders +template_dir="$PWD" +source_dir="$(cd $template_dir/../source; pwd -P)" +root_dir="$template_dir/.." + +echo "------------------------------------------------------------------------------" +echo "[Init] Clean old folders" +echo "------------------------------------------------------------------------------" + +cd $root_dir +if [ -d ".venv" ]; then + rm -rf ".venv" +fi + +echo "------------------------------------------------------------------------------" +echo "[Env] Create virtual environment and install dependencies" +echo "------------------------------------------------------------------------------" + +virtualenv .venv +source .venv/bin/activate + +cd $source_dir +pip install -r $source_dir/requirements-dev.txt +cd - + +echo "------------------------------------------------------------------------------" +echo "[Test] Run pytest with coverage" +echo "------------------------------------------------------------------------------" +cd $source_dir +# setup coverage report path +coverage_report_path=$source_dir/tests/coverage-reports/source.coverage.xml +echo "coverage report path set to $coverage_report_path" + +pytest --cov --cov-report=term-missing --cov-report "xml:$coverage_report_path" + +# The pytest --cov with its parameters and .coveragerc generates a xml cov-report with `coverage/sources` list +# with absolute path for the source directories. To avoid dependencies of tools (such as SonarQube) on different +# absolute paths for source directories, this substitution is used to convert each absolute source directory +# path to the corresponding project relative path. The $source_dir holds the absolute path for source directory. +sed -i -e "s,$source_dir,source,g" $coverage_report_path + +# deactivate the virtual environment +deactivate + +cd $template_dir + diff --git a/source/.coveragerc b/source/.coveragerc new file mode 100644 index 0000000..3c91c64 --- /dev/null +++ b/source/.coveragerc @@ -0,0 +1,12 @@ +[run] +omit = + infrastructure/setup.py + infrastructure/cdk.out/* + tests/* +source = + infrastructure + aws_lambda + cdk_solution_helper_py + +[report] +fail_under = 80.0 \ No newline at end of file diff --git a/source/aws_lambda/__init__.py b/source/aws_lambda/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_batch_inference_job/__init__.py b/source/aws_lambda/create_batch_inference_job/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_batch_inference_job/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_batch_inference_job/handler.py b/source/aws_lambda/create_batch_inference_job/handler.py new file mode 100644 index 0000000..9c163e5 --- /dev/null +++ b/source/aws_lambda/create_batch_inference_job/handler.py @@ -0,0 +1,75 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="batchInferenceJob", + status="batchInferenceJob.status", + config={ + "jobName": { + "source": "event", + "path": "serviceConfig.jobName", + }, + "solutionVersionArn": { + "source": "event", + "path": "serviceConfig.solutionVersionArn", + }, + "filterArn": { + "source": "event", + "path": "serviceConfig.filterArn", + "default": "omit", + }, + "numResults": { + "source": "event", + "path": "serviceConfig.numResults", + "default": "omit", + }, + "jobInput": { + "source": "event", + "path": "serviceConfig.jobInput", + }, + "jobOutput": {"source": "event", "path": "serviceConfig.jobOutput"}, + "roleArn": {"source": "environment", "path": "ROLE_ARN"}, + "batchInferenceJobConfig": { + "source": "event", + "path": "serviceConfig.batchInferenceJobConfig", + "default": "omit", + }, + "maxAge": { + "source": "event", + "path": "workflowConfig.maxAge", + "default": "omit", + "as": "seconds", + }, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a batch inference job in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured batch inference job + """ + return event.get("resource") # return the batch inference job diff --git a/source/aws_lambda/create_campaign/__init__.py b/source/aws_lambda/create_campaign/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_campaign/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_campaign/handler.py b/source/aws_lambda/create_campaign/handler.py new file mode 100644 index 0000000..5e82fd5 --- /dev/null +++ b/source/aws_lambda/create_campaign/handler.py @@ -0,0 +1,64 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="campaign", + config={ + "name": { + "source": "event", + "path": "serviceConfig.name", + }, + "solutionVersionArn": { + "source": "event", + "path": "serviceConfig.solutionVersionArn", + }, + "minProvisionedTPS": { + "source": "event", + "path": "serviceConfig.minProvisionedTPS", + "as": "int", + }, + "campaignConfig": { + "source": "event", + "path": "serviceConfig.campaignConfig", + "default": "omit", + }, + "maxAge": { + "source": "event", + "path": "workflowConfig.maxAge", + "default": "omit", + "as": "seconds", + }, + }, + status="campaign.status", +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a campaign in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured dataset + """ + return event.get("resource") # return the campaign diff --git a/source/aws_lambda/create_dataset/__init__.py b/source/aws_lambda/create_dataset/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_dataset/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_dataset/handler.py b/source/aws_lambda/create_dataset/handler.py new file mode 100644 index 0000000..500c326 --- /dev/null +++ b/source/aws_lambda/create_dataset/handler.py @@ -0,0 +1,52 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="dataset", + config={ + "name": { + "source": "event", + "path": "name", + }, + "datasetType": { + "source": "event", + "path": "datasetType", + }, + "datasetGroupArn": { + "source": "event", + "path": "datasetGroupArn", + }, + "schemaArn": {"source": "event", "path": "schemaArn"}, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a dataset in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured dataset + """ + return event.get("resource") # return the dataset diff --git a/source/aws_lambda/create_dataset_group/__init__.py b/source/aws_lambda/create_dataset_group/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_dataset_group/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_dataset_group/handler.py b/source/aws_lambda/create_dataset_group/handler.py new file mode 100644 index 0000000..6f9adfb --- /dev/null +++ b/source/aws_lambda/create_dataset_group/handler.py @@ -0,0 +1,54 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Tracer, Logger, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +tracer = Tracer() +logger = Logger() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="datasetGroup", + status="datasetGroup.status", + config={ + "name": { + "source": "event", + "path": "serviceConfig.name", + }, + "roleArn": { + "source": "environment", + "path": "KMS_ROLE_ARN", + "default": "omit", + }, + "kmsKeyArn": { + "source": "environment", + "path": "KMS_KEY_ARN", + "default": "omit", + }, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a dataset group in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured dataset group + """ + return event.get("resource") # return the dataset group diff --git a/source/aws_lambda/create_dataset_import_job/__init__.py b/source/aws_lambda/create_dataset_import_job/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_dataset_import_job/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_dataset_import_job/handler.py b/source/aws_lambda/create_dataset_import_job/handler.py new file mode 100644 index 0000000..158422a --- /dev/null +++ b/source/aws_lambda/create_dataset_import_job/handler.py @@ -0,0 +1,59 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="datasetImportJob", + status="datasetImportJob.status", + config={ + "jobName": { + "source": "event", + "path": "serviceConfig.jobName", + }, + "datasetArn": { + "source": "event", + "path": "serviceConfig.datasetArn", + }, + "dataSource": { + "source": "event", + "path": "serviceConfig.dataSource", + }, + "roleArn": {"source": "environment", "path": "ROLE_ARN"}, + "maxAge": { + "source": "event", + "path": "workflowConfig.maxAge", + "default": "omit", + "as": "seconds", + }, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a dataset import job in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured dataset import job + """ + return event.get("resource") # return the dataset import job diff --git a/source/aws_lambda/create_event_tracker/__init__.py b/source/aws_lambda/create_event_tracker/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_event_tracker/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_event_tracker/handler.py b/source/aws_lambda/create_event_tracker/handler.py new file mode 100644 index 0000000..2158e56 --- /dev/null +++ b/source/aws_lambda/create_event_tracker/handler.py @@ -0,0 +1,48 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="eventTracker", + status="eventTracker.status", + config={ + "name": { + "source": "event", + "path": "name", + }, + "datasetGroupArn": { + "source": "event", + "path": "datasetGroupArn", + }, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create an event tracker in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured event tracker + """ + return event.get("resource") # return the event tracker diff --git a/source/aws_lambda/create_filter/__init__.py b/source/aws_lambda/create_filter/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_filter/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_filter/handler.py b/source/aws_lambda/create_filter/handler.py new file mode 100644 index 0000000..943d441 --- /dev/null +++ b/source/aws_lambda/create_filter/handler.py @@ -0,0 +1,52 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="filter", + status="filter.status", + config={ + "name": { + "source": "event", + "path": "serviceConfig.name", + }, + "datasetGroupArn": { + "source": "event", + "path": "serviceConfig.datasetGroupArn", + }, + "filterExpression": { + "source": "event", + "path": "serviceConfig.filterExpression", + }, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a filter in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured dataset + """ + return event.get("resource") # return the filter diff --git a/source/aws_lambda/create_schema/__init__.py b/source/aws_lambda/create_schema/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_schema/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_schema/handler.py b/source/aws_lambda/create_schema/handler.py new file mode 100644 index 0000000..12fbd1e --- /dev/null +++ b/source/aws_lambda/create_schema/handler.py @@ -0,0 +1,44 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="schema", + config={ + "name": { + "source": "event", + "path": "serviceConfig.name", + }, + "schema": {"source": "event", "path": "serviceConfig.schema", "as": "string"}, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a schema in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured schema + """ + return event.get("resource") # return the resource diff --git a/source/aws_lambda/create_solution/__init__.py b/source/aws_lambda/create_solution/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_solution/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_solution/handler.py b/source/aws_lambda/create_solution/handler.py new file mode 100644 index 0000000..a53adf4 --- /dev/null +++ b/source/aws_lambda/create_solution/handler.py @@ -0,0 +1,73 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="solution", + status="solution.status", + config={ + "name": { + "source": "event", + "path": "serviceConfig.name", + }, + "performHPO": { + "source": "event", + "path": "serviceConfig.performHPO", + "default": "omit", + }, + "performAutoML": { + "source": "event", + "path": "serviceConfig.performAutoML", + "default": "omit", + }, + "recipeArn": { + "source": "event", + "path": "serviceConfig.recipeArn", + "default": "omit", + }, + "datasetGroupArn": { + "source": "event", + "path": "serviceConfig.datasetGroupArn", + }, + "eventType": { + "source": "event", + "path": "serviceConfig.eventType", + "default": "omit", + }, + "solutionConfig": { + "source": "event", + "path": "serviceConfig.solutionConfig", + "default": "omit", + }, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a solution in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured solution version + """ + return event.get("resource") # return the solution diff --git a/source/aws_lambda/create_solution_version/__init__.py b/source/aws_lambda/create_solution_version/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_solution_version/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_solution_version/handler.py b/source/aws_lambda/create_solution_version/handler.py new file mode 100644 index 0000000..98c8af0 --- /dev/null +++ b/source/aws_lambda/create_solution_version/handler.py @@ -0,0 +1,60 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.sfn_middleware import PersonalizeResource + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@PersonalizeResource( + resource="solutionVersion", + status="solutionVersion.status", + config={ + "solutionArn": { + "source": "event", + "path": "serviceConfig.solutionArn", + }, + "trainingMode": { + "source": "event", + "path": "serviceConfig.trainingMode", + "default": "omit", + }, + "maxAge": { + "source": "event", + "path": "workflowConfig.maxAge", + "default": "omit", + "as": "seconds", + }, + "solutionVersionArn": { + "source": "event", + "path": "workflowConfig.solutionVersionArn", + "default": "omit", + }, + }, +) +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict: + """Create a solution version in Amazon Personalize based on the configuration in `event` + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the configured solution version + """ + return event.get("resource") # return the solution version diff --git a/source/aws_lambda/create_timestamp/__init__.py b/source/aws_lambda/create_timestamp/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/create_timestamp/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/create_timestamp/handler.py b/source/aws_lambda/create_timestamp/handler.py new file mode 100644 index 0000000..c92d0ca --- /dev/null +++ b/source/aws_lambda/create_timestamp/handler.py @@ -0,0 +1,31 @@ +# ###################################################################################################################### +# 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 datetime +from typing import Dict, Any + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = Logger() +tracer = Tracer() + + +@tracer.capture_lambda_handler +def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> str: + """Create a timestamp matching YYYY_mm_dd_HH_MM_SS + :param event: AWS Lambda Event + :param context: AWS Lambda Context + :return: the timestamp (string) + """ + return datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S") diff --git a/source/aws_lambda/get_next_scheduled_event/build.gradle b/source/aws_lambda/get_next_scheduled_event/build.gradle new file mode 100644 index 0000000..1199154 --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/build.gradle @@ -0,0 +1,72 @@ +/* + * 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. + */ + +plugins { + id 'java' + id 'jacoco' + id 'org.sonarqube' version '3.3' +} + +group 'com.amazonaws.solutions' +version '1.0-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.7.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' + implementation 'com.amazonaws:aws-lambda-java-core:1.2.1' + implementation platform('com.amazonaws:aws-java-sdk-bom:1.11.1000') + implementation 'org.quartz-scheduler:quartz:2.3.2' +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +jacocoTestReport { + reports { + xml.enabled true + csv.enabled false + html.enabled false + } +} + +test { + useJUnitPlatform() +} +test.finalizedBy jacocoTestReport + +sonarqube { + properties { + property "sonar.sourceEncoding", "UTF-8" + } +} + +tasks.named('sonarqube').configure { + dependsOn test +} + +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } +} + +build.dependsOn buildZip \ No newline at end of file diff --git a/source/aws_lambda/get_next_scheduled_event/gradle/wrapper/gradle-wrapper.jar b/source/aws_lambda/get_next_scheduled_event/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/source/aws_lambda/get_next_scheduled_event/gradle/wrapper/gradle-wrapper.properties b/source/aws_lambda/get_next_scheduled_event/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..da9702f --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/source/aws_lambda/get_next_scheduled_event/gradlew b/source/aws_lambda/get_next_scheduled_event/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/source/aws_lambda/get_next_scheduled_event/settings.gradle b/source/aws_lambda/get_next_scheduled_event/settings.gradle new file mode 100644 index 0000000..c77d37d --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/settings.gradle @@ -0,0 +1,14 @@ +/* + * 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. + */ + +rootProject.name = 'sfn-schedule-task' \ No newline at end of file diff --git a/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEvent.java b/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEvent.java new file mode 100644 index 0000000..32c1c6e --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEvent.java @@ -0,0 +1,59 @@ +/* + * 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. + */ + +package com.amazonaws.solutions.schedule_sfn_task; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import org.quartz.CronExpression; + +import java.security.SecureRandom; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.Date; +import java.util.TimeZone; + +public class HandleScheduleEvent implements RequestHandler { + @Override + public String handleRequest(ScheduleEvent event, Context context) { + try { + setNextSchedule(event); + } catch (ParseException e) { + throw new ScheduleException(e.getMessage()); + } + return event.getNext(); + } + + private ScheduleEvent setNextSchedule(ScheduleEvent event) throws ParseException { + String schedule = event.getSchedule(); + SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX"); + dateFormatter.setTimeZone(TimeZone.getTimeZone("UTC")); + + // create the expression (this throws a ParseException on failure) + CronExpression expression = new CronExpression(schedule); + + // set up the next date as a string + int seconds = getRandomSeconds(); + Date dt = Date.from(expression.getNextValidTimeAfter(Date.from(Instant.now())).toInstant().plusSeconds(seconds)); + String dtText = dateFormatter.format(dt); + event.setNext(event.setNext(dtText)); + + return event; + } + + private int getRandomSeconds() { + SecureRandom random = new SecureRandom(); + return random.nextInt(60); + } +} diff --git a/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleEvent.java b/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleEvent.java new file mode 100644 index 0000000..e03e0b2 --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleEvent.java @@ -0,0 +1,60 @@ +/* + * 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. + */ + +package com.amazonaws.solutions.schedule_sfn_task; + +public class ScheduleEvent { + private String schedule; + private String next; + + public ScheduleEvent() { + } + + public String getNext() { + return next; + } + + public String setNext(String next) { + this.next = next; + return next; + } + + public void setSchedule(String schedule) { + /* + cron schedules have 7 fields (seconds, minutes, hours, day-of-month month day-of-week and year), we use only the + last 6 fields (omitting seconds). To do this, we always set seconds to 0, and keep the remainder of the provided + schedule. When generating a next scheduled time, we use a random number of seconds in the minute to avoid hot + spots at the start of each minute. An example string schedule provided might look like * * * * ? * (e.g. every + minute) + */ + schedule = validateSchedule(schedule); + this.schedule = "0 " + schedule; + } + + public String getSchedule() { + return schedule; + } + + private String validateSchedule(String schedule) { + schedule = schedule + .replace("cron(", "") + .replace(")", ""); + + String[] fields = schedule.split("\\s+"); + + if(fields.length != 6) { + throw new ScheduleException("schedule " + schedule + " is not a valid schedule (requires 6 fields)"); + } + return schedule; + } +} diff --git a/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleException.java b/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleException.java new file mode 100644 index 0000000..b845178 --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/src/main/java/com/amazonaws/solutions/schedule_sfn_task/ScheduleException.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +package com.amazonaws.solutions.schedule_sfn_task; + +public class ScheduleException extends RuntimeException { + public ScheduleException(String message) { + super(message); + } +} diff --git a/source/aws_lambda/get_next_scheduled_event/src/test/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEventTest.java b/source/aws_lambda/get_next_scheduled_event/src/test/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEventTest.java new file mode 100644 index 0000000..f6ce94a --- /dev/null +++ b/source/aws_lambda/get_next_scheduled_event/src/test/java/com/amazonaws/solutions/schedule_sfn_task/HandleScheduleEventTest.java @@ -0,0 +1,58 @@ +/* + * 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. + */ + +package com.amazonaws.solutions.schedule_sfn_task; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; + + +class HandleScheduleEventTest { + private ScheduleEvent event; + private HandleScheduleEvent handler; + + @BeforeEach + public void setUp() { + event = new ScheduleEvent(); + handler = new HandleScheduleEvent(); + } + + @Test + @DisplayName("returns ISO 8601 in UTC with seconds") + public void testScheduleEventOutput() { + this.event.setSchedule("cron(* * * * ? *)"); + String result = handler.handleRequest(this.event, null); + + DateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + Assertions.assertDoesNotThrow(() -> { + sdf.parse(result); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"cron(1)", "* * * * * *", "* * *", "* * * * *"}) + @DisplayName("com.amazonaws.solutions.schedule_sfn_task.ScheduleEvent invalid representation raises com.amazonaws.solutions.schedule_sfn_task.ScheduleException") + public void testScheduleEventInvalid(String schedule) { + Assertions.assertThrows(ScheduleException.class, () -> { + this.event.setSchedule(schedule); + handler.handleRequest(this.event, null); + }); + } +} \ No newline at end of file diff --git a/source/aws_lambda/s3_event/__init__.py b/source/aws_lambda/s3_event/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/s3_event/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/s3_event/handler.py b/source/aws_lambda/s3_event/handler.py new file mode 100644 index 0000000..e40254b --- /dev/null +++ b/source/aws_lambda/s3_event/handler.py @@ -0,0 +1,108 @@ +# ###################################################################################################################### +# 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 os +from typing import List + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.metrics import MetricUnit +from aws_lambda_powertools.utilities.data_classes import S3Event + +from aws_solutions.core.helpers import get_service_client +from shared.personalize_service import Configuration +from shared.sfn_middleware import set_bucket, start_execution + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +def topic_arn() -> str: + """ + Get the SNS topic ARN from environment variable + :return: The SNS topic ARN + """ + return os.environ["SNS_TOPIC_ARN"] + + +def solution_name() -> str: + """ + Get the Solution Name from environment variable + :return: the solution name + """ + return os.environ["SOLUTION_NAME"] + + +def send_configuration_error(errors: List[str]): + sns = get_service_client("sns") + subject = f"{solution_name()} Notifications" + + message = "There were errors detected when reading a personalization job configuration file:\n\n" + for error in errors: + logger.error(f"Personalization job configuration error: {error}") + message += f" - {error}\n" + message += "\nPlease correct these errors and upload the configuration again." + + sns.publish( + TopicArn=topic_arn(), + Message=message, + Subject=subject, + ) + + +@metrics.log_metrics +@tracer.capture_lambda_handler +def lambda_handler(event, context): + """Handles an S3 Event Notification (for any .json file written to any subfolder in "train/" + :param dict event: AWS Lambda Event (in this case, an S3 Event message) + :param context: + :return: None + """ + event: S3Event = S3Event(event) + bucket = event.bucket_name + s3 = get_service_client("s3") + + for record in event.records: + key = record.s3.get_object.key + logger.info( + f"processing Amazon S3 event notification record for s3://{bucket}/{key}" + ) + metrics.add_metric("ConfigurationsProcessed", unit=MetricUnit.Count, value=1) + + s3_config = s3.get_object(Bucket=bucket, Key=key) + config_text = s3_config.get("Body").read().decode("utf-8") + + # create the configuration, check for errors + configuration = Configuration() + configuration.load(config_text) + if configuration.errors: + send_configuration_error(configuration.errors) + metrics.add_metric( + "ConfigurationsProcessedFailures", unit=MetricUnit.Count, value=1 + ) + return + + # configuration has loaded, validate it + configuration.validate() + if configuration.errors: + metrics.add_metric( + "ConfigurationsProcessedFailures", unit=MetricUnit.Count, value=1 + ) + send_configuration_error(configuration.errors) + else: + config = configuration.config_dict + config = set_bucket(config, bucket, key) + metrics.add_metric( + "ConfigurationsProcessedSuccesses", unit=MetricUnit.Count, value=1 + ) + start_execution(config) diff --git a/source/aws_lambda/scheduler/__init__.py b/source/aws_lambda/scheduler/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/scheduler/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/scheduler/handler.py b/source/aws_lambda/scheduler/handler.py new file mode 100644 index 0000000..e22d7af --- /dev/null +++ b/source/aws_lambda/scheduler/handler.py @@ -0,0 +1,54 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Dict + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.utilities.typing import LambdaContext + +from shared.scheduler.base import Scheduler +from shared.scheduler.task import Task +from shared.scheduler.task_resource import TaskResource + +logger = Logger() +tracer = Tracer() +scheduler = Scheduler() +metrics = Metrics(service="Scheduler") + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@TaskResource +def create_schedule(task: Task, _: LambdaContext) -> Dict: + return scheduler.create(task) + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@TaskResource +def read_schedule(task: Task, _: LambdaContext) -> Dict: + return scheduler.read(task) + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@TaskResource +def update_schedule(task: Task, _: LambdaContext) -> Dict: + return scheduler.update(task) + + +@metrics.log_metrics +@tracer.capture_lambda_handler +@TaskResource +def delete_schedule(task: Task, _: LambdaContext) -> Dict: + return scheduler.delete(task) diff --git a/source/aws_lambda/shared/__init__.py b/source/aws_lambda/shared/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/shared/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/shared/date_helpers.py b/source/aws_lambda/shared/date_helpers.py new file mode 100644 index 0000000..9c44eda --- /dev/null +++ b/source/aws_lambda/shared/date_helpers.py @@ -0,0 +1,35 @@ +# ###################################################################################################################### +# 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 datetime + +import parsedatetime as pdt +from aws_lambda_powertools import Logger + +logger = Logger() + + +def parse_datetime(tm: str) -> int: + if "month" in tm: + logger.warning( + "while months are supported, they are based off of the calendar of the start of year 1 CE" + ) + if "year" in tm: + logger.warning( + "while years are supported, they are based off of the calendar of the start of year 1 CE" + ) + + start_of_time = datetime.datetime.min + cal = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) + timedelta = cal.parseDT(tm, sourceTime=start_of_time)[0] - start_of_time + return int(timedelta.total_seconds()) diff --git a/source/aws_lambda/shared/exceptions.py b/source/aws_lambda/shared/exceptions.py new file mode 100644 index 0000000..be0f6e1 --- /dev/null +++ b/source/aws_lambda/shared/exceptions.py @@ -0,0 +1,32 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + + +class ResourcePending(Exception): + pass + + +class SolutionVersionPending(Exception): + pass + + +class ResourceFailed(Exception): + pass + + +class ResourceInvalid(Exception): + pass + + +class ResourceNeedsUpdate(Exception): + pass diff --git a/source/aws_lambda/shared/personalize_service.py b/source/aws_lambda/shared/personalize_service.py new file mode 100644 index 0000000..f8a83ed --- /dev/null +++ b/source/aws_lambda/shared/personalize_service.py @@ -0,0 +1,1022 @@ +# ###################################################################################################################### +# 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 json +import re +from datetime import datetime +from pathlib import Path +from typing import Callable, Dict, Optional, List, Union + +import avro.schema +import botocore.exceptions +import jmespath +from aws_lambda_powertools import Logger, Metrics +from aws_lambda_powertools.metrics import MetricUnit, SchemaValidationError +from botocore.stub import Stubber +from dateutil.tz import tzlocal + +from aws_solutions.core import ( + get_service_client, + get_aws_partition, + get_aws_region, + get_aws_account, +) +from shared.exceptions import ( + ResourcePending, + ResourceNeedsUpdate, + ResourceFailed, + SolutionVersionPending, +) +from shared.resource import ( + Resource, + Dataset, + EventTracker, + DatasetGroup, + DatasetImportJob, + Solution, + SolutionVersion, + BatchInferenceJob, + Schema, + Filter, + Campaign, +) +from shared.s3 import S3 +from shared.scheduler import Schedule, ScheduleError + +logger = Logger() +metrics = Metrics() + +STATUS_CREATING = ("ACTIVE", "CREATE PENDING", "CREATE IN_PROGRESS") +CRON_ANY_WILDCARD = "?" +CRON_MIN_MAX_YEAR = (1970, 2199) +SOLUTION_PARAMETERS = (("maxAge", Resource), ("solutionVersionArn", SolutionVersion)) + + +def get_duplicates(items): + if isinstance(items, str): + return [] + elif isinstance(items, list): + s = set() + return list(set(i for i in items if i in s or s.add(i))) + + +class Personalize: + def __init__(self): + self.cli = get_service_client("personalize") + + def arn(self, resource: Resource, name: str): + arn = f"arn:{get_aws_partition()}:personalize:{get_aws_region()}:{get_aws_account()}:{resource.name.dash}/{name}" + return {f"{resource.name.camel}Arn": arn} + + def list(self, resource: Resource, filters: Optional[Dict] = None): + if not filters: + filters = {} + list_fn_name = f"list_{resource.name.snake}s" + paginator = self.cli.get_paginator(list_fn_name) + iterator = paginator.paginate(**filters) + for page in iterator: + resource_key = [ + k + for k in list(page.keys()) + if k not in ("ResponseMetadata", "nextToken") + ].pop() + for item in page[resource_key]: + yield item + + def describe(self, resource: Resource, **kwargs): + """ + Describe a resource in Amazon Personalize + :param resource: the resource to describe + :param kwargs: the resource keyword arguments + :return: the resource from Amazon Personalize + """ + logger.debug(f"describing {resource.name.camel}") + if resource.name.camel == "dataset": + return self.describe_dataset(**kwargs) + elif resource.name.camel == "datasetImportJob": + return self.describe_dataset_import_job(**kwargs) + elif resource.name.camel == "solutionVersion": + return self.describe_solution_version(**kwargs) + elif resource.name.camel == "eventTracker": + return self.describe_event_tracker(**kwargs) + elif resource.name.camel == "batchInferenceJob": + return self.describe_batch_inference_job(**kwargs) + elif resource.name.camel == "campaign": + return self.describe_with_update(resource, **kwargs) + else: + return self.describe_default(resource, **kwargs) + + def describe_default(self, resource: Resource, **kwargs): + """ + Describe a resource in Amazon Personalize by deriving its ARN from its name + :param resource: the resource to describe + :param kwargs: the resource keyword arguments + :return: the response from Amazon Personalize + """ + describe_fn_name = f"describe_{resource.name.snake}" + describe_fn = getattr(self.cli, describe_fn_name) + return describe_fn(**self.arn(resource, kwargs["name"])) + + def _check_solution(self, sv_arn_expected: str, sv_arn_received: str) -> bool: + """ + Check if solution versions sv_received and sv_expected have the same solution ARN + :param sv_arn_expected: the first solution version + :param sv_arn_received: the second solution version + :return: None + """ + sol_arn_expected = sv_arn_expected.rsplit("/", 1)[0] + sol_arn_received = sv_arn_received.rsplit("/", 1)[0] + if sol_arn_expected != sol_arn_received: + raise ResourceFailed( + f"Expected solution ARN {sol_arn_expected} but got {sol_arn_received}. This can happen if a user " + f"modifies a resource out-of-band with the solution, or if you have attempted to use a resource of the " + f"same name and a different configuration across dataset groups" + ) + + def describe_with_update(self, resource: Resource, **kwargs): + """ + Describe a resource / determine if it requires an update + :param resource: the resource to update/ describe + :param kwargs: the resource keyword arguments to validate + :return: the response from Amazon Personalize + """ + result = self.describe_default(resource, **kwargs) + for k, v in kwargs.items(): + received = result[resource.name.camel][k] + expected = v + + # check the solution matches + if k == "solutionVersionArn": + self._check_solution(expected, received) + + if result[resource.name.camel].get(k) != v: + raise ResourceNeedsUpdate() + return result + + def _remove_solution_parameters(self, resource: Resource, kwargs): + """ + Remove solution parameters for the keyword arguments presented + :param kwargs: + :return: the kwargs with the solution parameters removed + """ + for key, resource_type in SOLUTION_PARAMETERS: + if isinstance(resource, resource_type): + kwargs.pop(key, None) + return kwargs + + def _describe_from_parent( + self, resource: Resource, parent: Resource, condition: Callable = None, **kwargs + ): + """ + Describe a resource from Amazon Personalize by listing from its parent, then filtering the list on `condition` + :param resource: the Amazon Personalize resource (e.g. dataset) + :param parent: the Amazon Personalize resources' parent (e.g. dataset_group) + :param condition: a condition to filter the child resources on (e.g. lambda job: job["status"] in STATUS_CREATING) + :param kwargs: the keyword arguments that would be passed to create this Amazon Personalize Resource + :return: the first discovered child fulfilling the `condition` as listed from the parent + """ + parent_arn = kwargs[f"{parent.name.camel}Arn"] + list_fn_kwargs = {f"{parent.name.camel}Arn": parent_arn} + + children = self.list(resource=resource, filters=list_fn_kwargs) + if condition: + child = next( + iter( + sorted( + [child for child in children if condition(child)], + key=lambda child: child["creationDateTime"], + reverse=True, + ) + ), + None, + ) + else: + child = next(iter(child for child in children), None) + + if not child: + raise self.cli.exceptions.ResourceNotFoundException( + { + "Code": "ResourceNotFoundException", + "Message": f"Could not find {resource.name.camel} for {parent.name.camel} {parent_arn}", + }, + f"List{resource.name.camel[0].upper()}{resource.name.camel[1:]}s", + ) + else: + # finalize by describing the listed child + + describe_fn_name = f"describe_{resource.name.snake}" + describe_fn = getattr(self.cli, describe_fn_name) + describe_arn = f"{resource.name.camel}Arn" + child = describe_fn(**{describe_arn: child[describe_arn]}) + + return child + + def describe_dataset(self, **kwargs): + """ + Do a list to list all datasets for a specific dataset group instead of a describe + :param kwargs: the resource keyword arguments + :return: the response from Amazon Personalize representing the listed dataset + """ + dataset_type = kwargs["datasetType"].upper() + dataset = self._describe_from_parent( + resource=Dataset(), + parent=DatasetGroup(), + condition=lambda dataset: dataset["datasetType"] == dataset_type, + **kwargs, + ) + + return dataset + + def is_current( # NOSONAR - allow higher complexity + self, + old_job: Dict, + new_job: Dict, + name_key: Optional[str] = None, + s3: Optional[S3] = None, + ): + if name_key: + old_job_name = old_job[name_key] + new_job_name = new_job.get(name_key, "UNKNOWN") + + # this is a current job + if old_job_name == new_job_name: + logger.info(f"{new_job_name} may be current") + return True + else: + arn_key = next( + iter(key for key in old_job.keys() if key.endswith("Arn")), "UNKNOWN" + ) + old_job_name = old_job.get(arn_key, "UNKNOWN") + + # check if the job is active/ creating, otherwise filter out + old_job_status = old_job["status"] + if old_job_status not in STATUS_CREATING: + logger.debug( + f"{old_job_name} has status {old_job_status} which is not active or creating" + ) + return False + + # check if the job is within maxAge if provided + max_age = new_job.get("maxAge", None) + if max_age and old_job_status == "ACTIVE": + now_dt = datetime.now(tzlocal()) + job_dt = old_job["lastUpdatedDateTime"] + job_age = (now_dt - job_dt).total_seconds() + + job_past_max_age = job_age > max_age + + # if we need to compare to an S3 object - include the check + if s3: + data_dt = s3.last_modified + new_data_available = data_dt > job_dt + else: + new_data_available = True + + if job_past_max_age and new_data_available: + logger.debug(f"{old_job_name} is not current") + return False + elif job_past_max_age and not new_data_available: + logger.info( + f"{old_job_name} is not current, but no new data is available" + ) + return True + elif not job_past_max_age: + logger.info( + f"{old_job_name} remains current ({int(max_age - job_age)}s remaining)" + ) + return True + elif max_age and old_job_status != "ACTIVE": + # this handles the case where we're working with solution version updates (since they do not have a name) + logger.debug(f"{old_job_name} remains current as it is {old_job_status}") + return True + else: + logger.debug(f"{old_job_name} is active") + return True + + def describe_dataset_import_job(self, **kwargs): + """ + Do a list to list all dataset import jobs for a specific dataset and return the latest one + :param kwargs: the resource keyword arguments + :return: the response from Amazon Personalize representing the listed dataset import job + """ + s3_url: str = kwargs["dataSource"]["dataLocation"] + + s3 = S3(url=s3_url) + contents_exist = s3.exists + + if not contents_exist: + raise s3.cli.meta.client.exceptions.NoSuchKey( + { + "Code": "NoSuchKey", + "Message": f"Could not find csv content at {s3_url}", + }, + "HeadObject", + ) + + def is_active_import(job: Dict): + return self.is_current( + new_job=kwargs, + old_job=job, + name_key="jobName", + s3=s3, + ) + + return self._describe_from_parent( + resource=DatasetImportJob(), + parent=Dataset(), + condition=is_active_import, + **kwargs, + ) + + def describe_solution_version(self, **kwargs): + def is_active_solution_version(job: Dict): + return self.is_current( + new_job=kwargs, + old_job=job, + name_key="solutionVersionArn", + ) + + solution_version = self._describe_from_parent( + resource=SolutionVersion(), + parent=Solution(), + condition=is_active_solution_version, + **kwargs, + ) + + self._record_offline_metrics(solution_version) + return solution_version + + def describe_event_tracker(self, **kwargs): + return self._describe_from_parent( + resource=EventTracker(), + parent=DatasetGroup(), + condition=lambda job: job["status"] in STATUS_CREATING, + **kwargs, + ) + + def describe_batch_inference_job(self, **kwargs): + def is_active_batch_inference_job(job: Dict): + return self.is_current(new_job=kwargs, old_job=job, name_key="jobName") + + return self._describe_from_parent( + resource=BatchInferenceJob(), + parent=SolutionVersion(), + condition=is_active_batch_inference_job, + **kwargs, + ) + + def update(self, resource: Resource, **kwargs): + update_fn_name = f"update_{resource.name.snake}" + update_fn = getattr(self.cli, update_fn_name) + + # set up the ARN to update + kwargs_arn = self.arn(resource, kwargs.pop("name")) + kwargs.update(kwargs_arn) + + try: + result = update_fn(**kwargs) + except self.cli.exceptions.LimitExceededException as exc: + if resource.has_soft_limit: + logger.warning(f"soft limit encountered: {exc['Error']['Message']}") + raise ResourcePending() # raise ResourcePending to allow the step function to retry later + else: + raise # this is not a retryable service limit + except self.cli.exceptions.ResourceInUseException: + raise ResourcePending() + + return result + + def create(self, resource: Resource, **kwargs): + create_fn_name = f"create_{resource.name.snake}" + create_fn = getattr(self.cli, create_fn_name) + + # always remove the workflow configuration parameters before create + kwargs = self._remove_solution_parameters(resource, kwargs) + + try: + result = create_fn(**kwargs) + self.add_metric(resource) + except self.cli.exceptions.LimitExceededException as exc: + if resource.has_soft_limit: + logger.warning(f"soft limit encountered: {exc['Error']['Message']}") + raise ResourcePending() # raise ResourcePending to allow the step function to retry later + else: + raise # this is not a retryable service limit + + # for solution versions, raise an exception to save the version on create + if resource.name.camel == "solutionVersion": + raise SolutionVersionPending(f"{result['solutionVersionArn']}") + + return result + + def add_metric(self, resource: Resource): + metrics.add_metric( + f"{resource.name.snake.replace('_', ' ').title().replace(' ', '')}Created", + unit=MetricUnit.Count, + value=1, + ) + + def _flush_metrics(self): + """ + Flush the current recorded metrics to stdout (EMF) + :return: None + """ + try: + current_metrics = metrics.serialize_metric_set() + print(json.dumps(current_metrics)) + except SchemaValidationError as exc: + logger.info( + f"metrics not flushed: {str(exc)}" + ) # no metrics to serialize or no namespace + metrics.clear_metrics() + + def _record_offline_metrics(self, solution_version: Dict) -> None: + """ + Record the solution version offline metrics to CloudWatch + :param solution_version: The described solution version + :return: None + """ + self._flush_metrics() + + # change the metric dimensions for tracking personalize solution metrics + metrics.add_dimension("service", "SolutionMetrics") + metrics.add_dimension( + "solutionArn", solution_version["solutionVersion"]["solutionArn"] + ) + metrics._metric_units.append("None") + + metrics_response = self.cli.get_solution_metrics( + solutionVersionArn=solution_version["solutionVersion"]["solutionVersionArn"] + ) + for name, value in metrics_response["metrics"].items(): + metrics.add_metric(name, "None", float(value)) + + # flush the solution offline metrics and reset the metric dimensions + self._flush_metrics() + + @property + def exceptions(self): + return self.cli.exceptions + + +class ServiceModel: + """Lists all resources in Amazon Personalize for lookup against the dataset group ARN""" + + _arn_ownership = {} + + def __init__(self, cli: Personalize): + self.cli = cli + + dsgs = self._arns(self.cli.list(DatasetGroup())) + for dsg in dsgs: + logger.debug(f"listing children of {dsg}") + self._list_children(DatasetGroup(), dsg, dsg) + + def owned_by(self, resource_arn, dataset_group_owner: str) -> bool: + """ + Check + :param resource_arn: the resource ARN to check + :param dataset_group_owner: the dataset group owner expected + :return: True if the resource is managed by the dataset group, otherwise False + """ + if not dataset_group_owner.startswith("arn:"): + dataset_group_owner = f"arn:{get_aws_partition()}:personalize:{get_aws_region()}:{get_aws_account()}:dataset-group/{dataset_group_owner}" + + return dataset_group_owner == self._arn_ownership.get(resource_arn, False) + + def available(self, resource_arn: str) -> bool: + """ + Check if the requested ARN is available + :param resource_arn: requested ARN + :return: True if the ARN is available, otherwise False + """ + all_arns = set(self._arn_ownership.keys()).union( + set(self._arn_ownership.values()) + ) + return resource_arn not in all_arns + + def _list_children(self, parent: Resource, parent_arn, dsg: str) -> None: + """ + Recursively list the children of a resource + :param parent: the parent Resource + :param parent_arn: the parent Resource ARN + :param dsg: the parent dataset group ARN + :return: None + """ + for c in parent.children: + child_arns = self._arns( + self.cli.list(c, filters={f"{parent.name.camel}Arn": parent_arn}) + ) + + for arn in child_arns: + logger.debug(f"listing children of {arn}") + self._arn_ownership[arn] = dsg + self._list_children(c, arn, dsg) + + def _arns(self, l: List[Dict]) -> List[str]: + """ + Lists the first ARN found for each resource in a list of resources + :param l: the list of resources + :return: the list of ARNs + """ + return [ + [v for k, v in resource.items() if k.endswith("Arn")][0] for resource in l + ] + + +class InputValidator: + @classmethod + def validate(cls, method: str, expected_params: Dict) -> None: + """ + Validate an Amazon Personalize resource using the botocore stubber + :return: None. Raises ParamValidationError if the InputValidator fails to validate + """ + cli = get_service_client("personalize") + func = getattr(cli, method) + with Stubber(cli) as stubber: + stubber.add_response(method, {}, expected_params) + func(**expected_params) + + +class Configuration: + _schema = [ + { + "datasetGroup": [ + "serviceConfig", + {"workflowConfig": [{"schedules": ["import"]}, "maxAge"]}, + ] + }, + { + "datasets": [ + { + "users": [ + {"dataset": ["serviceConfig"]}, + {"schema": ["serviceConfig"]}, + ] + }, + { + "items": [ + {"dataset": ["serviceConfig"]}, + {"schema": ["serviceConfig"]}, + ] + }, + { + "interactions": [ + {"dataset": ["serviceConfig"]}, + {"schema": ["serviceConfig"]}, + ] + }, + ] + }, + { + "eventTracker": ["serviceConfig"], + }, + { + "filters": [ + [ + "serviceConfig", + ] + ] + }, + { + "solutions": [ + [ + "serviceConfig", + {"workflowConfig": {"schedules": ["full", "update", "maxAge"]}}, + {"campaigns": [["serviceConfig"]]}, + { + "batchInferenceJobs": [ + [ + "serviceConfig", + {"workflowConfig": ["schedule", "maxAge"]}, + ] + ] + }, + ] + ] + }, + ] + + def __init__(self): + self._configuration_errors = [] + self.config_dict = {} + + def load(self, content: Union[Path, str]): + if isinstance(content, Path): + config_str = content.read_text(encoding="utf-8") + else: + config_str = content + + self.config_dict = self._decode(config_str) + + def validate(self): + self._validate_not_empty() + self._validate_keys() + self._validate_dataset_group() + self._validate_schemas() + self._validate_datasets() + self._validate_event_tracker() + self._validate_filters() + self._validate_solutions() + self._validate_solution_update() + self._validate_cron_expressions( + "datasetGroup.workflowConfig.schedules.import", + "solutions[].workflowConfig.schedules.full", + "solutions[].workflowConfig.schedules.update", + "solutions[].batchInferenceJobs[].workflowConfig.schedule", + ) + self._validate_naming() + + return len(self._configuration_errors) == 0 + + @property + def errors(self) -> List[str]: + return self._configuration_errors + + def _decode(self, config_str) -> Dict: + """ + Decoded value the JSON string config_str or return an empty dictionary + :param config_str: the json string + :return: dictionary + """ + try: + return json.loads(config_str) + except json.JSONDecodeError as exc: + self._configuration_errors.append(f"Could not validate JSON: {exc}") + return {} + + def _validate_resource(self, resource: Resource, expected_params): + expected_params = expected_params.copy() + + try: + InputValidator.validate(f"create_{resource.name.snake}", expected_params) + except botocore.exceptions.ParamValidationError as exc: + self._configuration_errors.append(str(exc).replace("\n", " ")) + + def _validate_dataset_group(self, path="datasetGroup.serviceConfig"): + dataset_group = jmespath.search(path, self.config_dict) + if not dataset_group: + self._configuration_errors.append( + f"A datasetGroup must be provided at path datasetGroup" + ) + else: + self._validate_resource(DatasetGroup(), dataset_group) + + def _validate_event_tracker(self, path="eventTracker.serviceConfig"): + event_tracker = jmespath.search(path, self.config_dict) + + # no event tracker provided - nothing to validate + if not event_tracker: + return + if not isinstance(event_tracker, dict): + self._configuration_errors.append(f"{path} must be an object") + return + + event_tracker["datasetGroupArn"] = DatasetGroup().arn("validation") + self._validate_resource(EventTracker(), event_tracker) + + def _validate_filters(self, path="filters[].serviceConfig"): + filters = jmespath.search(path, self.config_dict) or {} + for idx, _filter in enumerate(filters): + if not self._validate_type( + _filter, dict, f"filters[{idx}].serviceConfig must be an object" + ): + continue + + _filter["datasetGroupArn"] = DatasetGroup().arn("validation") + self._validate_resource(Filter(), _filter) + + def _validate_type(self, var, typ, err: str): + validates = isinstance(var, typ) + if not validates: + self._configuration_errors.append(err) + return validates + + def _validate_solutions(self, path="solutions[]"): + solutions = jmespath.search(path, self.config_dict) or {} + for idx, _solution in enumerate(solutions): + campaigns = _solution.get("campaigns", []) + if self._validate_type( + campaigns, list, f"solutions[{idx}].campaigns must be a list" + ): + self._validate_campaigns(f"solutions[{idx}].campaigns", campaigns) + + batch_inference_jobs = _solution.get("batchInferenceJobs", []) + if batch_inference_jobs and self._validate_type( + batch_inference_jobs, + list, + f"solutions[{idx}].batchInferenceJobs must be a list", + ): + self._validate_batch_inference_jobs( + path=f"solutions[{idx}].batchInferenceJobs", + solution_name=_solution.get("serviceConfig", {}).get("name", ""), + batch_inference_jobs=batch_inference_jobs, + ) + + _solution = _solution.get("serviceConfig") + if not self._validate_type( + _solution, dict, f"solutions[{idx}].serviceConfig must be an object" + ): + continue + + _solution["datasetGroupArn"] = DatasetGroup().arn("validation") + self._validate_resource(Solution(), _solution) + + def _validate_solution_update(self): + valid_recipes = [ + "arn:aws:personalize:::recipe/aws-hrnn-coldstart", + "arn:aws:personalize:::recipe/aws-user-personalization", + ] + invalid = ( + jmespath.search( + f"solutions[?workflowConfig.schedules.update && (serviceConfig.recipeArn != '{valid_recipes[0]}' || serviceConfig.recipeArn != '{valid_recipes[1]}')].serviceConfig.name", + self.config_dict, + ) + or [] + ) + for solution_name in invalid: + self._configuration_errors.append( + f"solution {solution_name} does not support solution version incremental updates - please use `full` instead of `update`." + ) + + def _validate_solution_versions(self, path: str, solution_versions: List[Dict]): + for idx, solution_version_config in enumerate(solution_versions): + current_path = f"{path}.solutionVersions[{idx}]" + + solution_version = solution_version_config.get("solutionVersion") + if not self._validate_type( + solution_version, + dict, + f"{current_path}.solutionVersion must be an object", + ): + continue + else: + solution_version["solutionArn"] = Solution().arn("validation") + self._validate_resource(SolutionVersion(), solution_version) + + def _validate_campaigns(self, path, campaigns: List[Dict]): + for idx, campaign_config in enumerate(campaigns): + current_path = f"{path}.campaigns[{idx}]" + + campaign = campaign_config.get("serviceConfig") + if not self._validate_type( + campaign, dict, f"{current_path}.serviceConfig must be an object" + ): + continue + else: + campaign["solutionVersionArn"] = SolutionVersion().arn("validation") + self._validate_resource(Campaign(), campaign) + + def _validate_batch_inference_jobs( + self, path, solution_name, batch_inference_jobs: List[Dict] + ): + for idx, batch_job_config in enumerate(batch_inference_jobs): + current_path = f"{path}.batchInferenceJobs[{idx}]" + + batch_job = batch_job_config.get("serviceConfig") + if not self._validate_type( + batch_job, dict, f"{current_path}.batchInferenceJob must be an object" + ): + continue + else: + # service does not validate the batch job length client-side + job_name = f"batch_{solution_name}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}" + if len(job_name) > 63: + self._configuration_errors.append( + f"The generated batch inference job name {job_name} is longer than 63 characters. Use a shorter solution name." + ) + + # some values are provided by the solution - we introduce placeholders + batch_job.update( + { + "solutionVersionArn": SolutionVersion().arn("validation"), + "jobName": job_name, + "roleArn": "roleArn", + "jobInput": {"s3DataSource": {"path": "s3://data-source"}}, + "jobOutput": { + "s3DataDestination": {"path": "s3://data-destination"} + }, + } + ) + self._validate_resource(BatchInferenceJob(), batch_job) + + def _validate_rate(self, expression): + rate_re = re.compile( + r"rate\((?P\d+) (?P(minutes?|hours?|day?s)\))" + ) + match = rate_re.match(expression) + + if not match: + self._configuration_errors.append( + f"invalid rate ScheduleExpression {expression}" + ) + + def _validate_cron_expressions( # NOSONAR - allow higher complexity + self, *paths: List[str] + ) -> None: + """ + Validate all cron expressions found in paths + :param paths: the list of jmespath paths to validate as cron expressions + :return: None + """ + expressions = [] + for path in paths: + result = jmespath.search(path, self.config_dict) + if not result: + logger.debug(f"no schedule found at {path}") + continue + if isinstance(result, str): + expressions.append(result) + elif isinstance(result, list): + for item in result: + if isinstance(item, str): + expressions.append(item) + else: + self._configuration_errors.append( + f"unexpected type at path {path}, expected string" + ) + else: + self._configuration_errors.append( + f"unexpected type at path {path}, expected string or list" + ) + for expression in expressions: + try: + Schedule(expression=expression) + except ScheduleError as exc: + self._configuration_errors.append(str(exc)) + + def _validate_not_empty(self): + if not self.config_dict: + self._configuration_errors.append("Configuration should not be empty") + + def _validate_datasets(self) -> None: + """ + Perform a validation of the datasets up front + :return: None + """ + datasets = jmespath.search("datasets", self.config_dict) + if not datasets: + logger.warning("typical usage includes a dataset declaration") + return + + datasets = { + "users": jmespath.search( + "datasets.users.dataset.serviceConfig", self.config_dict + ), + "items": jmespath.search( + "datasets.items.dataset.serviceConfig", self.config_dict + ), + "interactions": jmespath.search( + "datasets.interactions.dataset.serviceConfig", self.config_dict + ), + } + + if not datasets["interactions"]: + self._configuration_errors.append( + "You must at minimum create an interactions dataset and declare its schema" + ) + + for dataset_name, dataset in datasets.items(): + if dataset: + if not self._validate_type( + dataset, dict, f"datasets.{dataset_name} must be an object" + ): + return + + # some values are provided by the solution - we introduce placeholders + SolutionVersion().arn("validation") + dataset.update( + { + "datasetGroupArn": DatasetGroup().arn("validation"), + "schemaArn": Schema().arn("validation"), + "datasetType": dataset_name, + } + ) + self._validate_resource(Dataset(), dataset) + + def _validate_schemas(self) -> None: + """ + Perform a validation of the schemas up front + :return: None + """ + users_schema = jmespath.search( + "datasets.users.schema.serviceConfig", self.config_dict + ) + items_schema = jmespath.search( + "datasets.items.schema.serviceConfig", self.config_dict + ) + interactions_schema = jmespath.search( + "datasets.interactions.schema.serviceConfig", self.config_dict + ) + + self._validate_schema("users", users_schema) + self._validate_schema("items", items_schema) + self._validate_schema("interactions", interactions_schema) + + def _validate_schema(self, name: str, schema: Optional[Dict]) -> None: + if not schema: + return # nothing to validate - schema wasn't provided + + avro_schema = schema.get("schema", {}) + avro_schema_name = schema.get("name") + + # check for schema name + if not avro_schema_name: + self._configuration_errors.append(f"The {name} schema name is missing") + + # check for schema + if not avro_schema: + self._configuration_errors.append(f"The {name} schema is missing") + else: + try: + avro.schema.parse(json.dumps(avro_schema)) + except avro.schema.SchemaParseException as exc: + self._configuration_errors.append( + f"The {name} schema is not valid: {exc}" + ) + + self._validate_resource( + Schema(), + { + "schema": json.dumps(avro_schema), + "name": avro_schema_name, + }, + ) + + def _validate_keys(self, config: Dict = None, schema: List = None, path=""): + """ + Validate the configuration in config_dict against allowed_keys + :param config_dict: The dictionary to validate + :param schema: The allowed keys + :param path: The path config_dict (used in recursion to identify a jmespath path) + :return: None + """ + if not config: + config = self.config_dict + if not schema: + schema = self._schema + + if isinstance(config, list): + self._validate_list(config, schema, path) + elif isinstance(config, dict): + self._validate_dict(config, schema, path) + else: + self._configuration_errors.append( + f"an unknown validation error occurred at {path}" + ) + + def _validate_list(self, config: List, schema: List, path=""): + for idx, item in enumerate(config): + current_path = f"{path}[{idx}]" + self._validate_keys(item, schema[0], current_path) + + def _validate_dict(self, config: Dict, schema: List, path=""): + allowed = [ + k + if isinstance(k, str) + else next(iter(k.keys())) + if isinstance(k, dict) + else k[0] + for k in schema + ] + sub_validations = [i for i in schema if isinstance(i, dict)] + + for key, value in config.items(): + current_path = [path, key] + current_path = ".".join([i for i in current_path if i]) + if key not in allowed: + self._configuration_errors.append( + f"key {current_path} is not an allowed key" + ) + + try: + sub_validation = [v for v in sub_validations if v.get(key)].pop() + self._validate_keys(value, sub_validation[key], current_path) + except IndexError: + pass # no sub validations + + def _validate_no_duplicates(self, name: str, path: str): + results = jmespath.search(path, self.config_dict) + duplicates = get_duplicates(results) + if duplicates: + self._configuration_errors.append( + f"duplicate {name} found: {', '.join(duplicates)}. Do not use the same {name} across solutions" + ) + + def _validate_naming(self): + """Validate that names of resources don't overlap in ways that might cause issues""" + self._validate_no_duplicates( + name="campaign names", path="solutions[].campaigns[].serviceConfig.name" + ) + self._validate_no_duplicates( + name="solution names", path="solutions[].serviceConfig.name" + ) diff --git a/source/aws_lambda/shared/resource/__init__.py b/source/aws_lambda/shared/resource/__init__.py new file mode 100644 index 0000000..e407fdf --- /dev/null +++ b/source/aws_lambda/shared/resource/__init__.py @@ -0,0 +1,53 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from shared.resource.base import Resource +from shared.resource.batch_inference_job import BatchInferenceJob +from shared.resource.campaign import Campaign +from shared.resource.dataset import Dataset +from shared.resource.dataset_group import DatasetGroup +from shared.resource.dataset_import_job import DatasetImportJob +from shared.resource.event_tracker import EventTracker +from shared.resource.filter import Filter +from shared.resource.schema import Schema +from shared.resource.solution import Solution +from shared.resource.solution_version import SolutionVersion + + +def get_resource(resource_type: str) -> Resource: + return { + "datasetGroup": DatasetGroup(), + "schema": Schema(), + "dataset": Dataset(), + "datasetImportJob": DatasetImportJob(), + "solution": Solution(), + "solutionVersion": SolutionVersion(), + "campaign": Campaign(), + "eventTracker": EventTracker(), + "filter": Filter(), + "batchInferenceJob": BatchInferenceJob(), + }[resource_type] + + +MANAGED_RESOURCES = [ + DatasetGroup(), + Schema(), + Dataset(), + DatasetImportJob(), + Solution(), + SolutionVersion(), + Campaign(), + EventTracker(), + Filter(), + BatchInferenceJob(), +] diff --git a/source/aws_lambda/shared/resource/base.py b/source/aws_lambda/shared/resource/base.py new file mode 100644 index 0000000..d37d873 --- /dev/null +++ b/source/aws_lambda/shared/resource/base.py @@ -0,0 +1,32 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from __future__ import annotations + +from typing import List + +from aws_solutions.core import get_aws_partition, get_aws_region, get_aws_account +from shared.resource.name import ResourceName + + +class Resource: + children: List[Resource] = [] + has_soft_limit: bool = False + + def __init__(self): + name = self.__class__.__name__ + name = name[0].lower() + name[1:] + self.name = ResourceName(name) + + def arn(self, name: str) -> str: + arn_prefix = f"arn:{get_aws_partition()}:personalize:{get_aws_region()}:{get_aws_account()}" + return f"{arn_prefix}:{self.name.dash}/{name}" diff --git a/source/aws_lambda/shared/resource/batch_inference_job.py b/source/aws_lambda/shared/resource/batch_inference_job.py new file mode 100644 index 0000000..2c58552 --- /dev/null +++ b/source/aws_lambda/shared/resource/batch_inference_job.py @@ -0,0 +1,17 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource.base import Resource + + +class BatchInferenceJob(Resource): + pass diff --git a/source/aws_lambda/shared/resource/campaign.py b/source/aws_lambda/shared/resource/campaign.py new file mode 100644 index 0000000..cafeaf0 --- /dev/null +++ b/source/aws_lambda/shared/resource/campaign.py @@ -0,0 +1,17 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource.base import Resource + + +class Campaign(Resource): + pass diff --git a/source/aws_lambda/shared/resource/dataset.py b/source/aws_lambda/shared/resource/dataset.py new file mode 100644 index 0000000..755bc98 --- /dev/null +++ b/source/aws_lambda/shared/resource/dataset.py @@ -0,0 +1,18 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource.base import Resource +from shared.resource.dataset_import_job import DatasetImportJob + + +class Dataset(Resource): + children = [DatasetImportJob()] diff --git a/source/aws_lambda/shared/resource/dataset_group.py b/source/aws_lambda/shared/resource/dataset_group.py new file mode 100644 index 0000000..20f8c29 --- /dev/null +++ b/source/aws_lambda/shared/resource/dataset_group.py @@ -0,0 +1,20 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource.base import Resource +from shared.resource.dataset import Dataset +from shared.resource.filter import Filter +from shared.resource.solution import Solution + + +class DatasetGroup(Resource): + children = [Dataset(), Filter(), Solution()] diff --git a/source/aws_lambda/shared/resource/dataset_import_job.py b/source/aws_lambda/shared/resource/dataset_import_job.py new file mode 100644 index 0000000..b9658de --- /dev/null +++ b/source/aws_lambda/shared/resource/dataset_import_job.py @@ -0,0 +1,17 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource.base import Resource + + +class DatasetImportJob(Resource): + has_soft_limit = True diff --git a/source/aws_lambda/shared/resource/event_tracker.py b/source/aws_lambda/shared/resource/event_tracker.py new file mode 100644 index 0000000..68346af --- /dev/null +++ b/source/aws_lambda/shared/resource/event_tracker.py @@ -0,0 +1,17 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource.base import Resource + + +class EventTracker(Resource): + pass diff --git a/source/aws_lambda/shared/resource/filter.py b/source/aws_lambda/shared/resource/filter.py new file mode 100644 index 0000000..3101bcc --- /dev/null +++ b/source/aws_lambda/shared/resource/filter.py @@ -0,0 +1,17 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource.base import Resource + + +class Filter(Resource): + pass diff --git a/source/aws_lambda/shared/resource/name.py b/source/aws_lambda/shared/resource/name.py new file mode 100644 index 0000000..bf0ebd8 --- /dev/null +++ b/source/aws_lambda/shared/resource/name.py @@ -0,0 +1,81 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + + +def camel_to_snake(s): + """ + Convert a camelCasedName to a snake_cased_name + :param s: the camelCasedName + :return: the snake_cased_name + """ + return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_") + + +def snake_to_camel(s: str): + """ + Convert a snake_cased_name to a camelCasedName + :param s: the snake_cased_name + :return: camelCasedName + """ + components = s.split("_") + return components[0] + "".join(y.title() for y in components[1:]) + + +def camel_to_dash(s: str): + """ + Convert a camelCasedName to a dash-cased-name + :param s: the camelCasedName + :return: the dash-cased-name + """ + return "".join(["-" + c.lower() if c.isupper() else c for c in s]).lstrip("-") + + +class ResourceName: + def __init__(self, name: str): + self.name = self._validated_name(name) + + def _validated_name(self, name) -> str: + """ + Validate that a name is valid, raising ValueError if it is not + :param name: the name to validate + :return: the validated name + """ + if not name.isalpha(): + raise ValueError("name must be camelCased") + if not name[0].islower(): + raise ValueError("name must start with a lower case character") + return name + + @property + def dash(self) -> str: + """ + Get the dash-cased-name of the resource + :return: the dash-cased-name + """ + return camel_to_dash(self.name) + + @property + def snake(self) -> str: + """ + Get the snake_cased_name of the resource + :return: the snake_cased_name + """ + return camel_to_snake(self.name) + + @property + def camel(self) -> str: + """ + Get the camelCasedName of the resource + :return: the camelCasedName + """ + return self.name diff --git a/source/aws_lambda/shared/resource/schema.py b/source/aws_lambda/shared/resource/schema.py new file mode 100644 index 0000000..4b34b44 --- /dev/null +++ b/source/aws_lambda/shared/resource/schema.py @@ -0,0 +1,17 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from shared.resource import Resource + + +class Schema(Resource): + pass diff --git a/source/aws_lambda/shared/resource/solution.py b/source/aws_lambda/shared/resource/solution.py new file mode 100644 index 0000000..1ba17b5 --- /dev/null +++ b/source/aws_lambda/shared/resource/solution.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. 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. # +# ###################################################################################################################### +from shared.resource.base import Resource +from shared.resource.campaign import Campaign +from shared.resource.solution_version import SolutionVersion + + +class Solution(Resource): + children = [Campaign(), SolutionVersion()] diff --git a/source/aws_lambda/shared/resource/solution_version.py b/source/aws_lambda/shared/resource/solution_version.py new file mode 100644 index 0000000..f4f359a --- /dev/null +++ b/source/aws_lambda/shared/resource/solution_version.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. 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. # +# ###################################################################################################################### +from shared.resource.base import Resource +from shared.resource.batch_inference_job import BatchInferenceJob + + +class SolutionVersion(Resource): + children = [BatchInferenceJob()] + has_soft_limit = True diff --git a/source/aws_lambda/shared/s3.py b/source/aws_lambda/shared/s3.py new file mode 100644 index 0000000..bcbd0ae --- /dev/null +++ b/source/aws_lambda/shared/s3.py @@ -0,0 +1,81 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from functools import lru_cache +from urllib.parse import urlparse + +import botocore.exceptions + +from aws_solutions.core import get_service_resource + + +class S3: + cli = get_service_resource("s3") + + def __init__(self, url, expected_suffix=".csv"): + self.cli = get_service_resource("s3") + self.expected_suffix = expected_suffix + self.url = url + self._last_modified = None + self.bucket, self.key = self._urlparse() + + def _urlparse(self): + parsed = urlparse(self.url, allow_fragments=False) + bucket = parsed.netloc + key = parsed.path.lstrip("/") + return bucket, key + + @property + @lru_cache() + def exists(self): + if self.url.endswith(self.expected_suffix): + return self._exists_one() + else: + return self._exists_any() + + @property + def last_modified(self): + if self.exists: + return self._last_modified + + def _exists_one(self): + try: + metadata = self.cli.Object(self.bucket, self.key) + metadata.load() + except botocore.exceptions.ClientError as exc: + if exc.response["Error"]["Code"] == "404": + return False + + self._last_modified = metadata.last_modified + return True + + def _exists_any(self): + try: + bucket = self.cli.Bucket(self.bucket) + objects = [ + o + for o in bucket.objects.filter(Prefix=self.key + "/", Delimiter="/") + if o.key.endswith(self.expected_suffix) + ] + latest = next( + iter(sorted(objects, key=lambda k: k.last_modified, reverse=True)), None + ) + except botocore.exceptions.ClientError as exc: + if exc.response["Error"]["Code"] == "404": + return False + + if latest: + self._last_modified = latest.last_modified + return True + else: + return False diff --git a/source/aws_lambda/shared/scheduler/__init__.py b/source/aws_lambda/shared/scheduler/__init__.py new file mode 100644 index 0000000..45cd046 --- /dev/null +++ b/source/aws_lambda/shared/scheduler/__init__.py @@ -0,0 +1,22 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +TASK_PK = "name" +TASK_SK = "version" +CRON_ANY_WILDCARD = "?" +CRON_MIN_MAX_YEAR = (1970, 2199) + +from shared.scheduler.base import Scheduler +from shared.scheduler.schedule import Schedule, ScheduleError +from shared.scheduler.task import Task +from shared.scheduler.task_resource import TaskResource diff --git a/source/aws_lambda/shared/scheduler/base.py b/source/aws_lambda/shared/scheduler/base.py new file mode 100644 index 0000000..1540f40 --- /dev/null +++ b/source/aws_lambda/shared/scheduler/base.py @@ -0,0 +1,282 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from __future__ import annotations + +import json +import os +from typing import Dict, Generator, Union, Optional + +from aws_lambda_powertools import Logger, Metrics +from aws_lambda_powertools.metrics import MetricUnit + +from aws_solutions.core import get_service_client, get_service_resource +from shared.scheduler import TASK_PK +from shared.scheduler.task import Task + +logger = Logger() +metrics = Metrics(service="Scheduler") + + +def dynamo_to_python(obj: Optional[Dict]) -> Optional[Task]: + # if no object was provided, there is nothing to convert + if not obj: + return None + + # unpack the flattened state_machine parameters and return the task + state_machine = { + "arn": obj.pop("state_machine_arn"), + "input": obj.pop("state_machine_input"), + } + obj["state_machine"] = state_machine + + task = Task(**obj) + return task + + +class Scheduler: + """Create schedules for events that invoke a step function""" + + def __init__(self): + self.ddb = get_service_resource("dynamodb") + self.ddb_cli = self.ddb.meta.client + self.table_name = os.environ.get("DDB_SCHEDULES_TABLE") + self.stepfunction = os.environ.get("DDB_SCHEDULER_STEPFUNCTION") + self.sfn_cli = get_service_client("stepfunctions") + self.table = self.ddb.Table(self.table_name) + + def create(self, task: Task) -> Optional[Task]: + """ + Create a new task and its associated schedule and start the waiter + :param task: the task to schedule + :return: None + """ + logger.info(f"creating scheduled task for {task}") + + if task.schedule.expression == "delete": + self.delete(task) + return + + changed = self._transact_put(task) + + if changed: + logger.info(f"enabling scheduled task for {task}") + self._enable_schedule(task) + + return task + + def read(self, task: Union[Task, str], version=0) -> Optional[Task]: + """ + Read the latest task from the table + :param task: the task to read + :param version: the task version (0 is always the latest version) + :return: Task + """ + task = Task(task) if isinstance(task, str) else task + try: + task = self.ddb_cli.get_item( + TableName=self.table.name, + Key=Task.key(task, version), + ConsistentRead=True, + ) + except self.ddb_cli.exceptions.ResourceNotFoundException: + return None + item = task.get("Item") + task = dynamo_to_python(item) + return task + + def update(self, task: Task) -> Optional[Task]: + """ + Update the task and its associated schedule + :param task: the Task to update + :return: Task + """ + logger.info(f"updating scheduled task for {task}") + + if task.schedule == "delete": + self.delete(task) + return + + changed = self._transact_put(task) + if changed: + logger.info(f"enabling scheduled task for {task}") + self._enable_schedule(task) + + return task + + def delete(self, task: Task) -> Optional[Task]: + latest_task = self.read(task) + versions = 0 if not latest_task else int(latest_task.latest) + + logger.info(f"disabling {task.name}") + self._disable_schedule(task) + + if not versions: + logger.info(f"no versions of task {task.name} to remove") + return None + + logger.info(f"removing all {versions} task(s) for {task.name}") + with self.table.batch_writer() as batch: + for i in range(versions + 1): + batch.delete_item(Key=Task.key(task, i)) + + metrics.add_metric("JobsDeleted", unit=MetricUnit.Count, value=1) + return task + + def list(self) -> Generator[str, None, None]: + """ + List the managed schedules + :return: Generator[str] of the schedules (by name) + """ + done = False + scan_kwargs = {"ProjectionExpression": TASK_PK} + start_key = None + discovered = set() + while not done: + if start_key: + scan_kwargs["ExclusiveStartKey"] = start_key + response = self.table.scan(**scan_kwargs) + items = response.get("Items", []) + for item in items: + item = item[TASK_PK] + if item not in discovered: + discovered.add(item) + yield item + start_key = response.get("LastEvaluatedKey", None) + done = start_key is None + + def _get_running_execution_arn(self, task: Task) -> Optional[str]: + paginator = self.sfn_cli.get_paginator("list_executions") + iterator = paginator.paginate( + stateMachineArn=self.stepfunction, statusFilter="RUNNING" + ) + for page in iterator: + executions = page.get("executions", []) + for execution in executions: + execution_name = execution["name"] + execution_arn = execution["executionArn"] + + # since the task name might be truncated in the execution ID we need to describe + # the execution input to get the full name. Try to avoid long/ duplicate task names + # in the first 67 characters of the task name for performance reasons + if execution_name.startswith(task.name[:67]): + schedule_input = json.loads( + self.sfn_cli.describe_execution( + executionArn=execution_arn, + )["input"] + ) + schedule_name = schedule_input.get("name") + if schedule_name == task.name: + return execution_arn + return None + + def _disable_schedule(self, task: Task) -> None: + execution_arn = self._get_running_execution_arn(task) + if execution_arn: + self.sfn_cli.stop_execution( + executionArn=execution_arn, + error="410", + cause=f"execution disabled for {task.name}", + ) + logger.info(f"disabled {task.name}") + else: + logger.info(f"{task.name} already disabled") + + def _enable_schedule(self, task: Task) -> None: + execution_arn = self._get_running_execution_arn(task) + if execution_arn: + self.sfn_cli.stop_execution( + executionArn=execution_arn, + error="301", + cause=f"execution superseded by {task.next_task_id}", + ) + + self.sfn_cli.start_execution( + stateMachineArn=self.stepfunction, + name=task.next_task_id, + input=json.dumps( + { + "name": task.name, + } + ), + ) + + def _transact_put(self, task: Task) -> bool: + if not task.schedule or not isinstance(task.schedule.expression, str): + raise ValueError( + "to create a task, it must have a schedule (e.g. cron(* * * * ? *)" + ) + if not isinstance(task.state_machine, dict): + raise ValueError("task state_machine must be a dictionary") + if ( + "arn" not in task.state_machine.keys() + or "input" not in task.state_machine.keys() + ): + raise ValueError("task state_machine must have an arn and input") + if not isinstance(task.state_machine["arn"], str): + raise ValueError("task state_machine.arn must be a string") + if not isinstance(task.state_machine["input"], dict): + raise ValueError("task state_machine.input must be a dictionary") + + latest_task = self.read(task) + version_curr = 0 if not latest_task else latest_task.latest + version_next = version_curr + 1 + + if version_curr != 0 and task == latest_task: + logger.info(f"task {task.name} unchanged from version {version_curr}") + return False + + if version_curr == 0: + metrics.add_metric("JobsCreated", unit=MetricUnit.Count, value=1) + + self.ddb_cli.transact_write_items( + TransactItems=[ + { + "Update": { + "TableName": self.table_name, + "Key": Task.key(task, 0), + # Conditional write makes the update idempotent here + # since the conditional check is on the same attribute + # that is being updated. + "ConditionExpression": "attribute_not_exists(#latest) OR #latest = :latest", + "UpdateExpression": "SET #latest = :version_next, #schedule = :schedule, #state_machine_input = :state_machine_input, #state_machine_arn = :state_machine_arn", + "ExpressionAttributeNames": { + "#latest": "latest", + "#schedule": "schedule", + "#state_machine_arn": "state_machine_arn", + "#state_machine_input": "state_machine_input", + }, + "ExpressionAttributeValues": { + ":latest": version_curr, + ":version_next": version_next, + ":schedule": task.schedule.expression, + ":state_machine_input": task.state_machine.get("input"), + ":state_machine_arn": task.state_machine.get("arn"), + }, + } + }, + { + "Put": { + "TableName": self.table_name, + "Item": { + **Task.key(task, version_next), + "schedule": task.schedule.expression, + "state_machine_input": task.state_machine.get("input"), + "state_machine_arn": task.state_machine.get("arn"), + }, + } + }, + ] + ) + logger.info(f"put scheduled task for {task.name} with version {version_next}") + return True diff --git a/source/aws_lambda/shared/scheduler/schedule.py b/source/aws_lambda/shared/scheduler/schedule.py new file mode 100644 index 0000000..2316470 --- /dev/null +++ b/source/aws_lambda/shared/scheduler/schedule.py @@ -0,0 +1,109 @@ +# ###################################################################################################################### +# 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 re +from dataclasses import dataclass, field + +import cronex + +from shared.scheduler import CRON_ANY_WILDCARD, CRON_MIN_MAX_YEAR + + +class ScheduleError(ValueError): + pass # NOSONAR (python:S1186) - is a sort of ValueError + + +@dataclass +class Schedule: + """Represents and validates a scheduled expression compatible with Scheduler""" + + expression: str = field(repr=True, compare=True) + _configuration_errors: list = field(repr=False, init=False, default_factory=list) + + def __post_init__(self): + self.validate() + + def validate(self): + """ + Validate the schedule expression, raising ScheduleError for invalid expressions + :return: None + """ + # no schedule provided - nothing to validate + if not self.expression: + raise ScheduleError("Task is missing a schedule") + + # schedule provided - validate it + if self.expression.startswith("cron(") and self.expression.endswith(")"): + self.expression = self._validate_cron() + elif self.expression == "delete": + pass # allow delete + else: + raise ScheduleError( + f'invalid schedule {self.expression}. Use a cron() schedule or "delete" to remove a schedule that already exists' + ) + + if self._configuration_errors: + raise ScheduleError(".".join(self._configuration_errors)) + + def _validate_cron(self) -> str: + """ + Perform a partial validation of the cron expression + :param expression: the cron expression e.g. cron(* * * * ? *) + :return: the expression e.g. * * * ? * + """ + schedule = self.expression + # fmt: off + cron_re = re.compile( + r"^cron\((?P[^ ]+) (?P[^ ]+) (?P[^ ]+) (?P[^ ]+) (?P[^ ]+) (?P[^ ]+)\)$" + ) + # fmt: on + match = cron_re.match(schedule) + + if not match: + self._configuration_errors.append( + f"invalid cron ScheduleExpression {schedule}. Should have 6 fields" + ) + else: + minutes = match.group("minutes") + hours = match.group("hours") + day_of_month = match.group("day_of_month") + month = match.group("month") + day_of_week = match.group("day_of_week") + year = match.group("year") + + if day_of_month != CRON_ANY_WILDCARD and day_of_week != CRON_ANY_WILDCARD: + self._configuration_errors.append( + f"invalid cron ScheduleExpression {schedule}. Do not specify day-of-month and day-of week in the same cron expression" + ) + + # validate the majority of the ScheduleExpression + try: + cronex.CronExpression( + f"{minutes} {hours} {day_of_month} {month} {day_of_week}" + ) + except ValueError as exc: + self._configuration_errors.append( + f"invalid cron ScheduleExpression: {exc}" + ) + + # cronex does not validate the year - validate separately + try: + cronex.parse_atom(year, CRON_MIN_MAX_YEAR) + except ValueError as exc: + self._configuration_errors.append( + f"invalid cron ScheduleExpression year: {exc}" + ) + + return ( + f"cron({minutes} {hours} {day_of_month} {month} {day_of_week} {year})" + ) diff --git a/source/aws_lambda/shared/scheduler/task.py b/source/aws_lambda/shared/scheduler/task.py new file mode 100644 index 0000000..8ed7809 --- /dev/null +++ b/source/aws_lambda/shared/scheduler/task.py @@ -0,0 +1,69 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Union, Dict +from uuid import uuid4 + +from shared.scheduler import TASK_PK, TASK_SK +from shared.scheduler.schedule import Schedule + + +@dataclass +class Task: + """Represents a Scheduler scheduled task""" + + name: str + schedule: Union[None, str, Schedule] = "" + state_machine: Dict = field(default_factory=dict, repr=False) + latest: Decimal = field(default=Decimal(0), repr=False, compare=False) + version: str = field(default="v0", repr=False, compare=False) + next_task_id: str = field(repr=False, compare=False, init=False) + + def __post_init__(self): + if self.schedule: + self.schedule = Schedule(self.schedule) + self.next_task_id = self.get_next_task_id() + + def __str__(self) -> str: + rv = f"{self.name}" + if self.schedule: + rv = f"{rv} ({self.schedule.expression})" + return rv + + def get_next_task_id(self) -> str: + """ + Get a random next task ID (max 80 characters length) + :return: + """ + return f"{self.name[:67]}-{uuid4().hex[:12]}" + + @staticmethod + def key(task: Union[Task, str], version: int = 0) -> Dict: + """ + Get the dynamo db key associated with this task + :param task: the full task name + :param version: the task version key to request (defaults to 0, the latest task) + :return: the key + """ + if isinstance(task, Task): + task_name = task.name + elif isinstance(task, str): + task_name = task + else: + raise ValueError("task must be a string or a Task") + + return {TASK_PK: task_name, TASK_SK: f"v{version}"} diff --git a/source/aws_lambda/shared/scheduler/task_resource.py b/source/aws_lambda/shared/scheduler/task_resource.py new file mode 100644 index 0000000..1950d13 --- /dev/null +++ b/source/aws_lambda/shared/scheduler/task_resource.py @@ -0,0 +1,38 @@ +# ###################################################################################################################### +# 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 dataclasses +import functools + +from shared.scheduler.schedule import Schedule +from shared.scheduler.task import Task + + +class TaskResource: + """Used as a decorator on AWS Lambda Functions to transform the AWS Lambda Event input as a Task""" + + def __init__(self, func): + functools.update_wrapper(self, func) + self.func = func + + def __call__(self, *args, **kwargs): + task: Task = Task(**args[0]) + task: Task = self.func(task, args[1], **kwargs) + + if not task: + return None + else: + # convert the schedule into a string + if isinstance(task.schedule, Schedule): + task.schedule = task.schedule.expression + return dataclasses.asdict(task) diff --git a/source/aws_lambda/shared/sfn_middleware.py b/source/aws_lambda/shared/sfn_middleware.py new file mode 100644 index 0000000..41868f2 --- /dev/null +++ b/source/aws_lambda/shared/sfn_middleware.py @@ -0,0 +1,289 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from __future__ import annotations + +import datetime +import decimal +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Any, Callable, Optional, List, Union +from uuid import uuid4 + +import jmespath +from aws_lambda_powertools import Logger + +from aws_solutions.core import get_service_client +from shared.date_helpers import parse_datetime +from shared.exceptions import ( + ResourcePending, + ResourceInvalid, + ResourceFailed, + ResourceNeedsUpdate, +) +from shared.personalize_service import Personalize +from shared.resource import get_resource + +STATUS_IN_PROGRESS = ( + "CREATE PENDING", + "CREATE IN_PROGRESS", + "DELETE PENDING", + "DELETE IN_PROGRESS", +) +STATUS_FAILED = "CREATE FAILED" +STATUS_ACTIVE = "ACTIVE" + +logger = Logger() + + +def json_handler(item): + if isinstance(item, datetime.datetime): + return item.isoformat() + elif isinstance(item, decimal.Decimal) and item.as_integer_ratio()[1] == 1: + return int(item) + elif isinstance(item, decimal.Decimal) and item.as_integer_ratio()[1] != 1: + return float(item) + raise TypeError("Unknown Type") + + +def set_defaults(config: Dict) -> Dict: + """ + Set the defaults for schedule/ solutions/ solution versions/ campaigns as empty if not set + :param config: the configuration dictionary + :return: the configuration with defaults set + """ + # always include/ override the current date + config["currentDate"] = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S") + + # always include a maxAge for the datasets + config.setdefault("datasetGroup", {}) + config["datasetGroup"].setdefault("workflowConfig", {}) + config["datasetGroup"]["workflowConfig"].setdefault("maxAge", "365 days") + + # by default, don't include a solution + solutions = config.setdefault("solutions", []) + for s_idx, solution in enumerate(solutions): + # by default, don't include a solution version + config["solutions"][s_idx].setdefault("solutionVersions", []) + # by default, don't include a campaign or batch inference job + config["solutions"][s_idx].setdefault("campaigns", []) + config["solutions"][s_idx].setdefault("batchInferenceJobs", []) + return config + + +def set_bucket(config: Dict, bucket: str, key: str) -> Dict: + config["bucket"] = {"name": bucket, "key": str(Path(key).parent)} + return config + + +def start_execution(config): + sfn = get_service_client("stepfunctions") + state_machine_arn = os.environ.get("STATE_MACHINE_ARN") + config = set_defaults(config) + + logger.info("starting state machine execution") + sfn.start_execution( + stateMachineArn=state_machine_arn, + name=str(uuid4()), + input=json.dumps(config, default=json_handler), + ) + + +@dataclass +class Parameter: + key: str + source: str + path: str + format_as: Optional[str] + default: Optional[str] + + def get_default(self): + if self.default == "omit": + return None + else: + return self.default + + def format(self, resolved): + if not self.format_as: + return resolved + + if self.format_as == "string": + return json.dumps(resolved) + elif self.format_as == "seconds": + return parse_datetime(resolved) + elif self.format_as == "int": + return int(resolved) + else: + raise ValueError(f"Invalid format_as value {self.format_as}") + + def resolve(self, event) -> Optional[Union[str, Dict, None]]: + if self.source == "event": + resolved = jmespath.search(self.path, event) + elif self.source == "environment": + resolved = os.environ.get(self.path) + else: + raise ValueError( + f"Missing or misconfigured event `source` (got {self.source})" + ) + + if not resolved: + resolved = self.get_default() + + if not resolved and self.default != "omit": + raise ValueError( + f"missing configuration for {self.key}, expected from {self.source} at path {self.path}" + ) + + if resolved: + return self.format(resolved) + else: + return None + + +@dataclass +class ResourceConfiguration: + event: Dict + config: Dict + parameters: List[Parameter] = field(default_factory=list, init=False) + + def __post_init__(self): + for key, source_configuration in self.config.items(): + if not isinstance(source_configuration, dict): + raise ValueError("config must be Dict[str, Dict[str, str]]") + + parameter = Parameter( + key=key, + source=source_configuration["source"], + path=source_configuration["path"], + default=source_configuration.get("default", None), + format_as=source_configuration.get("as", None), + ) + self.parameters.append(parameter) + + @property + def kwargs(self): + configuration = {} + for parameter in self.parameters: + resolved = parameter.resolve(self.event) + if resolved: + configuration[parameter.key] = resolved + logger.debug(configuration) + return configuration + + +class PersonalizeResource: + def __init__( + self, resource: str, status: str = None, config: Optional[Dict] = None + ): + self.resource: str = resource + self.status: str = status + self.config: Dict[str, Dict] = config if config else {} + + def check_status( # NOSONAR - allow higher complexity + self, resource: Dict[str, Any], **expected + ) -> Dict: + # Check for resource property mismatch (filters, solutions are not scoped to their dataset group) + received = resource.get(self.resource) + mismatch = [] + case_insensitive_keys = ["datasetType"] + + for expected_key, expected_value in expected.items(): + actual_value = received.get(expected_key) + + # some keys are json strings and should be converted to dict for comparison + if self.config.get(expected_key, {}).get("as") == "string": + expected_value = json.loads(expected_value) + actual_value = json.loads(actual_value) + + # some keys are case insensitive + if expected_key in case_insensitive_keys: + actual_value = actual_value.lower() + expected_value = expected_value.lower() + + # some parameters don't require checking: + if self.resource == "datasetImportJob" and expected_key == "jobName": + continue + if self.resource == "batchInferenceJob" and expected_key in { + "jobName", + "jobInput", + "jobOutput", + }: + continue + if self.resource == "solutionVersion" and expected_key == "trainingMode": + continue + if expected_key == "maxAge": + continue + + if actual_value != expected_value: + mismatch.append( + f"expected {expected_key} to be {expected_value} but got {actual_value}" + ) + if mismatch: + raise ResourceFailed( + f"{'. '.join(mismatch)}. This can happen if a user modifies a resource out-of-band " + f"with the solution, if you have attempted to use a resource of the same name and " + f"a different configuration across dataset groups, or are attempting multiple " + f"solution maintenance jobs at the same time" + ) + + # certain resources do not have a status (e.g. Schema) + if not self.status: + return resource + + status = jmespath.search(self.status, resource) or "invalid" + if status in STATUS_ACTIVE: + return resource + elif status in STATUS_IN_PROGRESS: + logger.debug({"message": "resource is pending", "resource": {**resource}}) + raise ResourcePending() + elif status in STATUS_FAILED: + logger.error({"message": "resource has failed", "resource": {**resource}}) + raise ResourceFailed() + else: + logger.error({"message": "resource is invalid", "resource": {**resource}}) + raise ResourceInvalid() + + def __call__(self, func: Callable): + def decorator(event, context): + cli = Personalize() + + config = ResourceConfiguration(event, self.config) + kwargs = config.kwargs + + # describe or create + resource = get_resource(self.resource) + try: + resource = cli.describe(resource, **kwargs) + except cli.exceptions.ResourceNotFoundException: + cli.create(resource, **kwargs) + raise ResourcePending() + except cli.exceptions.ResourceInUseException: + # this occurs during an update or a create on resume + raise ResourcePending() + except ResourceNeedsUpdate: + cli.update(resource, **kwargs) + raise ResourcePending() + + # check the status of the resource + self.check_status(resource, **kwargs) + + # convert any non-processable fields to something we can handle + event["resource"] = json.loads( + json.dumps( + jmespath.search(self.resource, resource), default=json_handler + ) + ) + return func(event, context) + + return decorator diff --git a/source/aws_lambda/sns_notification/__init__.py b/source/aws_lambda/sns_notification/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/aws_lambda/sns_notification/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/aws_lambda/sns_notification/handler.py b/source/aws_lambda/sns_notification/handler.py new file mode 100644 index 0000000..a92a267 --- /dev/null +++ b/source/aws_lambda/sns_notification/handler.py @@ -0,0 +1,127 @@ +# ###################################################################################################################### +# 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 json +import os +from typing import Dict, Optional + +from aws_lambda_powertools import Logger, Tracer, Metrics +from aws_lambda_powertools.metrics import MetricUnit +from aws_lambda_powertools.utilities.typing import LambdaContext + +from aws_solutions.core.helpers import ( + get_service_client, + get_aws_region, + get_aws_partition, +) + +logger = Logger() +tracer = Tracer() +metrics = Metrics() + + +UNKNOWN_SOURCE = "UNKNOWN" + + +def topic_arn() -> str: + """ + Get the SNS topic ARN from environment variable + :return: The SNS topic ARN + """ + return os.environ["SNS_TOPIC_ARN"] + + +def solution_name() -> str: + """ + Get the Solution Name from environment variable + :return: the solution name + """ + return os.environ["SOLUTION_NAME"] + + +class MessageBuilder: + """Builds error messages from AWS Step Functions Output""" + + def __init__(self, event: Dict, context: LambdaContext): + self.dataset_group = event.get("datasetGroup", UNKNOWN_SOURCE) + self.states_error = event.get("statesError", None) + self.service_error = event.get("serviceError", None) + self.error = event.get("statesError", event.get("serviceError", {})) + self.region = get_aws_region() + self.partition = get_aws_partition() + self.account = context.invoked_function_arn.split(":")[4] + + if self.error: + metrics.add_metric("JobFailure", unit=MetricUnit.Count, value=1) + self.message = self._build_error_message() + else: + metrics.add_metric("JobSuccess", unit=MetricUnit.Count, value=1) + self.message = self._build_success_message() + + def _build_error_message(self) -> str: + """ + Build the error message + :return: the error message (with optional traces) + """ + error_cause = json.loads(self.error.get("Cause", "{}")) + error_message = error_cause.get("errorMessage", UNKNOWN_SOURCE) + + message = f"There was an error running the personalization job for dataset group {self.dataset_group}\n\n" + message += f"Message: {error_message}\n\n" + traces = self.get_trace_link() + if traces: + message += f"Traces: {traces}" + return message + + def _build_success_message(self) -> str: + """ + Build the success message + :return: the success message + """ + console_link = f"https://console.aws.amazon.com/personalize/home?region={self.region}#arn:{self.partition}:personalize:{self.region}:{self.account}:dataset-group${self.dataset_group}/setup" + + message = f"The Personalization job for dataset group {self.dataset_group} is complete\n\n" + message += f"Link: {console_link}" + return message + + def get_trace_link(self) -> Optional[str]: + """ + Check if an X-Ray Trace Link deep can be provided, and provide it + :return: The X-Ray Trace link or None + """ + trace_id = os.environ.get("_X_AMZN_TRACE_ID", "").split(";")[0].strip("Root=") + + if trace_id: + trace_deep_link = f"https://console.aws.amazon.com/xray/home?region={self.region}#/traces/{trace_id}" + return trace_deep_link + else: + return None + + +@metrics.log_metrics +@tracer.capture_lambda_handler +def lambda_handler(event, context): + """Create an SNS notification email" + :param dict event: AWS Lambda Event + :param context: + :return: None + """ + sns = get_service_client("sns") + message = MessageBuilder(event, context).message + subject = f"{solution_name()} Notifications" + + logger.info("publishing message for event", extra={"event": event}) + sns.publish( + TopicArn=topic_arn(), + Message=message, + Subject=subject, + ) diff --git a/source/cdk_solution_helper_py/CHANGELOG.md b/source/cdk_solution_helper_py/CHANGELOG.md new file mode 100644 index 0000000..b5b0277 --- /dev/null +++ b/source/cdk_solution_helper_py/CHANGELOG.md @@ -0,0 +1,10 @@ +# Change Log +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). + +## [1.0.0] - 2021-09-23 +### Added +- initial release + diff --git a/source/cdk_solution_helper_py/README.md b/source/cdk_solution_helper_py/README.md new file mode 100644 index 0000000..4998e97 --- /dev/null +++ b/source/cdk_solution_helper_py/README.md @@ -0,0 +1,247 @@ +# CDK Solution Helper for Python and CDK +## Infrastructure Deployment Tooling + +This tooling helps you develop new AWS Solutions using the AWS CDK with an approach that is compatible with the +current AWS Solutions build pipeline. + +This README summarizes using the tool. + +## Prerequisites + +Install this package. It requires at least + +- Python 3.7 +- AWS CDK version 1.95.2 or higher + +To install the packages: + +``` +pip install /cdk_solution_helper_py/helpers_cdk # where is the path to the solution helper +pip install /cdk_solution_helper_py/helpers_common # where is the path to the solution helper +``` + +## 1. Create a new CDK application + +```shell script +mkdir -p your_solution_name/deployment +mkdir -p your_solution_name/source-infrastructure +cd your_solution_name/source/infrastructure +cdk init app --language=python . +``` + +## 2. Install the package + +``` +cd your_solution_name +virtualenv .venv +source ./.venv/bin/activate +pip install /cdk_solution_helper_py/helpers_cdk # where is the path to the solution helper +pip install /cdk_solution_helper_py/helpers_common # where is the path to the solution helper +``` + +# 3. Write CDK code using the helpers + +This might be a file called `app.py` in your CDK application directory + +```python +#!/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 logging +from pathlib import Path + +from aws_cdk import core +from aws_cdk.core import CfnParameter, Construct + +from aws_solutions.cdk import CDKSolution +from aws_solutions.cdk.stack import SolutionStack +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonFunction + +# The solution helper build script expects this logger to be used +logger = logging.getLogger("cdk-helper") + +# Initialize the CDKSolution helper - it will be used to build the templates in a solution-compatible manner +solution = CDKSolution(cdk_json_path=Path(__file__).parent.absolute() / "cdk.json") + + +# Inherit from SolutionStack to create a CDK app compatible with AWS Solutions +class MyStack(SolutionStack): + def __init__(self, scope: Construct, construct_id: str, description: str, template_filename, **kwargs): + super().__init__(scope, construct_id, description, template_filename, **kwargs) + + # add some parameters to the stack + self.solutions_template_options.add_parameter( + CfnParameter( + self, "Parameter1", description="This is a detailed parameter description" + ), + label="Description 1", + group="Group1", + ) + self.solutions_template_options.add_parameter( + CfnParameter( + self, "Parameter2", description="This is a detailed parameter description" + ), + label="Description 2", + group="Group1", + ) + + # add any custom metrics to your stack! + self.metrics.update({"your_custom_metric": "your_custom_metric_value"}) + + # example of adding an AWS Lambda function for Python + SolutionsPythonFunction( + self, + "ExampleLambdaFunction", + entrypoint=Path(__file__).parent.absolute() / "example_function" / "handler.py", + function="handler" + ) + + +@solution.context.requires("SOLUTION_NAME") +@solution.context.requires("SOLUTION_ID") +@solution.context.requires("VERSION") +@solution.context.requires("BUCKET_NAME") +def build_app(context): + """ + This is the main entrypoint to your solution. + The @solution.context decorators indicate that those are required CDK context variables + The solution.synthesizer is required as a synthesizer for each solution stack + """ + app = core.App(context=context) + + # add constructs to your CDK app that are compatible with AWS Solutions + MyStack( + scope=app, + construct_id="stack", + description="This is a demo AWS Solution CDK stack", + template_filename="hello-world.template", + synthesizer=solution.synthesizer, + ) + + return app.synth() + + +if __name__ == "__main__": + result = build_app() +``` + + +## 4. Build the solution for deployment + +You can use the [AWS CDK](https://aws.amazon.com/cdk/) to deploy the solution directly + +```shell script +# install the Python dependencies +cd +virtualenv .venv +source .venv/bin/activate +pip install -r source/requirements-build-and-test.txt + +# change into the infrastructure directory +cd source/infrastructure + +# set environment variables required by the solution - use your own bucket name here +export BUCKET_NAME="placeholder" + +# bootstrap CDK (required once - deploys a CDK bootstrap CloudFormation stack for assets) +cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess + +# deploy with CDK +cdk deploy +# +``` + +At this point, the stack will be built and deployed using CDK - the template will take on default CloudFormation +parameter values. To modify the stack parameters, you can use the `--parameters` flag in CDK deploy - for example: + +```shell script +cdk deploy --parameters [...] +``` + +## 5. Package the solution for release + +It is highly recommended to use CDK to deploy this solution (see step #1 above). While CDK is used to develop the +solution, to package the solution for release as a CloudFormation template use the `build-s3-cdk-dist` script: + +``` +cd /deployment + +export DIST_OUTPUT_BUCKET=my-bucket-name +export SOLUTION_NAME=my-solution-name +export VERSION=my-version + +build-s3-cdk-dist --source-bucket-name $DIST_OUTPUT_BUCKET --solution-name $SOLUTION_NAME --version-code $VERSION --cdk-app-path ../source/infrastructure/app.py --cdk-app-entrypoint app:build_app --sync +``` + +> **Note**: `build-s3-cdk-dist` will use your current configured `AWS_REGION` and `AWS_PROFILE`. To set your defaults +install the [AWS Command Line Interface](https://aws.amazon.com/cli/) and run `aws configure`. + +#### Parameter Details: + +- `$DIST_OUTPUT_BUCKET` - This is the global name of the distribution. For the bucket name, the AWS Region is added to +the global name (example: 'my-bucket-name-us-east-1') to create a regional bucket. The lambda artifact should be +uploaded to the regional buckets for the CloudFormation template to pick it up for deployment. +- `$SOLUTION_NAME` - The name of This solution (example: your-solution-name) +- `$VERSION` - The version number of the change + +> **Notes**: The `build_s3_cdk_dist` script expects the bucket name as one of its parameters, and this value should +not include the region suffix. See below on how to create the buckets expected by this solution: +> +> The `SOLUTION_NAME`, and `VERSION` variables might also be defined in the `cdk.json` file. + +## 3. Upload deployment assets to yur Amazon S3 buckets + +Create the CloudFormation bucket defined above, as well as a regional bucket in the region you wish to deploy. The +CloudFormation template is configured to pull the Lambda deployment packages from Amazon S3 bucket in the region the +template is being launched in. Create a bucket in the desired region with the region name appended to the name of the +bucket. eg: for us-east-1 create a bucket named: ```my-bucket-us-east-1```. + +For example: + +```bash +aws s3 mb s3://my-bucket-name --region us-east-1 +aws s3 mb s3://my-bucket-name-us-east-1 --region us-east-1 +``` + +Copy the built S3 assets to your S3 buckets: + +``` +use the --sync option of build-s3-cdk-dist to upload the global and regional assets +``` + +> **Notes**: Choose your desired region by changing region in the above example from us-east-1 to your desired region +of the S3 buckets. + +## 4. Launch the CloudFormation template + +* Get the link of `your-solution-name.template` uploaded to your Amazon S3 bucket. +* Deploy the solution to your account by launching a new AWS CloudFormation stack using the link of the +`your-solution-name.template`. + +*** + +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. \ No newline at end of file diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/__init__.py new file mode 100644 index 0000000..24aae9c --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/__init__.py @@ -0,0 +1,33 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from pathlib import Path + +from aws_solutions.cdk.context import SolutionContext +from aws_solutions.cdk.stack import SolutionStack +from aws_solutions.cdk.synthesizers import SolutionStackSubstitions + + +class CDKSolution: + """ + A CDKSolution stores helper utilities for building AWS Solutions using the AWS CDK in Python + + :type cdk_json_path: Path + :param cdk_json_path: The full path to the cdk.json context for your application + :type qualifier: str + :param qualifier: A string that is added to all resources in the CDK bootstrap stack. The default value has no significance. + """ + + def __init__(self, cdk_json_path: Path, qualifier="hnb659fds"): + self.context = SolutionContext(cdk_json_path=cdk_json_path) + self.synthesizer = SolutionStackSubstitions(qualifier=qualifier) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aspects.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aspects.py new file mode 100644 index 0000000..aa55560 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aspects.py @@ -0,0 +1,28 @@ +# ##################################################################################################################### +# 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 +from aws_cdk.core import CfnCondition, IAspect, IConstruct + + +@jsii.implements(IAspect) +class ConditionalResources: + """Mark any CDK construct as conditional (this is useful to apply to stacks and L2+ constructs)""" + + def __init__(self, condition: CfnCondition): + self.condition = condition + + def visit(self, node: IConstruct): + if "is_cfn_element" in dir(node) and node.is_cfn_element(node): + node.cfn_options.condition = self.condition + elif "is_cfn_element" in dir(node.node.default_child): + node.node.default_child.cfn_options.condition = self.condition diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/__init__.py new file mode 100644 index 0000000..2895559 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/__init__.py @@ -0,0 +1,16 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_hash.hash import ( + ResourceHash, +) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/hash.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/hash.py new file mode 100644 index 0000000..d912e44 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/hash.py @@ -0,0 +1,84 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### +from pathlib import Path + +from aws_cdk.core import ( + Construct, + CfnResource, + Stack, +) + +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonFunction +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression + + +class ResourceHash(Construct): + """Used to create unique resource names based on the hash of the stack ID""" + + def __init__( + self, + scope: Construct, + construct_id: str, + purpose: str, + max_length: int, + ): + super().__init__(scope, construct_id) + + uuid = "ResourceHashFunction-b8785f53-1531-4bfb-a119-26aa638d7b19" + stack = Stack.of(self) + self._resource_name_function = stack.node.try_find_child(uuid) + + if not self._resource_name_function: + self._resource_name_function = SolutionsPythonFunction( + stack, + uuid, + entrypoint=Path(__file__).parent + / "src" + / "custom_resources" + / "hash.py", + function="handler", + ) + add_cfn_nag_suppressions( + resource=self._resource_name_function.node.default_child, + suppressions=[ + CfnNagSuppression( + "W89", "This AWS Lambda Function is not deployed to a VPC" + ), + CfnNagSuppression( + "W92", + "This AWS Lambda Function does not require reserved concurrency", + ), + ], + ) + + properties = { + "ServiceToken": self._resource_name_function.function_arn, + "Purpose": purpose, + "MaxLength": max_length, + } + + self.logical_name = f"{construct_id}HashResource" + self.resource_name_resource = CfnResource( + self, + self.logical_name, + type="Custom::ResourceHash", + properties=properties, + ) + + @property + def resource_name(self): + return self.resource_name_resource.get_att("Name") + + @property + def resource_id(self): + return self.resource_name_resource.get_att("Id") diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/hash.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/hash.py new file mode 100644 index 0000000..51e71c7 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/hash.py @@ -0,0 +1,88 @@ +# ##################################################################################################################### +# 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 logging +from hashlib import md5 +from os import getenv + +from crhelper import CfnResource + +logger = logging.getLogger(__name__) +helper = CfnResource(log_level=getenv("LOG_LEVEL", "WARNING")) + + +class StackId: + def __init__(self, event): + self.stack_id = event.get("StackId") + self.partition = self.get_arn_component(1) + self.service = self.get_arn_component(2) + self.region = self.get_arn_component(3) + self.account = self.get_arn_component(4) + self.stack_name = self.get_arn_component(5).split("/")[1] + + def get_arn_component(self, idx: int) -> str: + return self.stack_id.split(":")[idx] + + @property + def hash(self): + digest = md5() # NOSONAR - safe to hash, not for cryptographic purposes + digest.update(bytes(f"{self.stack_id.rsplit('/', 1)[0]}", "ascii")) + return digest.hexdigest().upper() + + +def get_property(event, property_name, property_default=None): + resource_prop = event.get("ResourceProperties", {}).get( + property_name, property_default + ) + if not resource_prop: + raise ValueError(f"missing required property {property_name}") + return resource_prop + + +@helper.create +def generate_hash(event, _): + """ + Generate a resource name containing a hash of the stack ID (without unique ID) and resource purpose. + This is useful when you need to create named IAM roles + + :param event: The CloudFormation custom resource event + :return: None + """ + stack_id = StackId(event) + purpose = get_property(event, "Purpose") + max_length = int(get_property(event, "MaxLength", 64)) + + name = f"{purpose}-{stack_id.hash[:8]}" + + if len(name) > max_length: + raise ValueError( + f"the derived resource name {name} is too long ({len(name)} / {max_length}) - please use a shorter Purpose" + ) + + logger.info(f"the derived resource name is {name}") + helper.Data["Name"] = name + helper.Data["Id"] = stack_id.hash + + +@helper.update +@helper.delete +def no_op(_, __): + pass # pragma: no cover + + +def handler(event, _): + """ + Handler entrypoint - see generate_hash for implementation details + :param event: The CloudFormation custom resource event + :return: PhysicalResourceId + """ + helper(event, _) # pragma: no cover diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/requirements.txt b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/requirements.txt new file mode 100644 index 0000000..76fcf16 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_hash/src/custom_resources/requirements.txt @@ -0,0 +1 @@ +crhelper==2.0.6 \ No newline at end of file diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/__init__.py new file mode 100644 index 0000000..0667198 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/__init__.py @@ -0,0 +1,16 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name.name import ( + ResourceName, +) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/name.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/name.py new file mode 100644 index 0000000..48d6cc4 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/name.py @@ -0,0 +1,90 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### +from pathlib import Path +from typing import Optional + +from aws_cdk.core import ( + Construct, + CfnResource, + Aws, + Stack, +) + +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonFunction +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression + + +class ResourceName(Construct): + """Used to create unique resource names of the format {stack_name}-{purpose}-{id}""" + + def __init__( + self, + scope: Construct, + construct_id: str, + purpose: str, + max_length: int, + resource_id: Optional[str] = None, + ): + super().__init__(scope, construct_id) + + uuid = "ResourceNameFunction-d45b185a-fe34-44ab-a375-17f89597d9ec" + stack = Stack.of(self) + self._resource_name_function = stack.node.try_find_child(uuid) + + if not self._resource_name_function: + self._resource_name_function = SolutionsPythonFunction( + stack, + uuid, + entrypoint=Path(__file__).parent + / "src" + / "custom_resources" + / "name.py", + function="handler", + ) + add_cfn_nag_suppressions( + resource=self._resource_name_function.node.default_child, + suppressions=[ + CfnNagSuppression( + "W89", "This AWS Lambda Function is not deployed to a VPC" + ), + CfnNagSuppression( + "W92", + "This AWS Lambda Function does not require reserved concurrency", + ), + ], + ) + + properties = { + "ServiceToken": self._resource_name_function.function_arn, + "Purpose": purpose, + "StackName": Aws.STACK_NAME, + "MaxLength": max_length, + } + if resource_id: + properties["Id"] = resource_id + + self.logical_name = f"{construct_id}NameResource" + self.resource_name_resource = CfnResource( + self, + self.logical_name, + type="Custom::ResourceName", + properties=properties, + ) + + @property + def resource_name(self): + return self.resource_name_resource.get_att("Name") + + @property + def resource_id(self): + return self.resource_name_resource.get_att("Id") diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/name.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/name.py new file mode 100644 index 0000000..2c76ef1 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/name.py @@ -0,0 +1,74 @@ +# ##################################################################################################################### +# 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 logging +from os import getenv +from uuid import uuid4 as uuid + +from crhelper import CfnResource + +logger = logging.getLogger(__name__) +helper = CfnResource(log_level=getenv("LOG_LEVEL", "WARNING")) + + +def get_property(event, property_name, property_default=None): + resource_prop = event.get("ResourceProperties", {}).get( + property_name, property_default + ) + if not resource_prop: + raise ValueError(f"missing required property {property_name}") + return resource_prop + + +@helper.create +def generate_name(event, _): + """ + Generate a resource name containing the stack name and the resource purpose. This is useful + when you need to associate policies that refer to a resource by name (and thus need + a predictable resource name). This is commonly used when associating policies with buckets + or other resources that might introduce a circular resource dependency + + :param event: The CloudFormation custom resource event + :return: None + """ + resource_id = get_property(event, "Id", uuid().hex[0:12]) + stack_name = get_property(event, "StackName") + purpose = get_property(event, "Purpose") + max_length = int(get_property(event, "MaxLength")) + + name = f"{stack_name}-{purpose}-{resource_id}".lower() + if len(name) > max_length: + logger.warning("cannot use stack name in bucket name - trying default") + name = f"{purpose}-{resource_id}".lower() + if len(name) > max_length: + raise ValueError( + f"the derived resource name {name} is too long ({len(name)} / {max_length}) - please use a shorter purpose or stack name" + ) + + logger.info(f"the derived resource name is {name}") + helper.Data["Name"] = name + helper.Data["Id"] = resource_id + + +@helper.update +@helper.delete +def no_op(_, __): + pass # pragma: no cover + + +def handler(event, _): + """ + Handler entrypoint - see generate_name for implementation details + :param event: The CloudFormation custom resource event + :return: PhysicalResourceId + """ + helper(event, _) # pragma: no cover diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/requirements.txt b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/requirements.txt new file mode 100644 index 0000000..76fcf16 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/resource_name/src/custom_resources/requirements.txt @@ -0,0 +1 @@ +crhelper==2.0.6 \ No newline at end of file diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/__init__.py new file mode 100644 index 0000000..f9dba6b --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/__init__.py @@ -0,0 +1,16 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.solutions_metrics.metrics import ( + Metrics, +) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/metrics.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/metrics.py new file mode 100644 index 0000000..50d4ee0 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/metrics.py @@ -0,0 +1,87 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### +from pathlib import Path +from typing import Dict + +from aws_cdk.core import ( + Construct, + CfnResource, + Fn, + CfnCondition, + Aws, +) + +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonFunction +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression + + +class Metrics(Construct): + """Used to track anonymous solution deployment metrics.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + metrics: Dict[str, str], + ): + super().__init__(scope, construct_id) + + if not isinstance(metrics, dict): + raise ValueError("metrics must be a dictionary") + + self._metrics_function = SolutionsPythonFunction( + self, + "MetricsFunction", + entrypoint=Path(__file__).parent + / "src" + / "custom_resources" + / "metrics.py", + function="handler", + ) + add_cfn_nag_suppressions( + resource=self._metrics_function.node.default_child, + suppressions=[ + CfnNagSuppression( + "W89", "This AWS Lambda Function is not deployed to a VPC" + ), + CfnNagSuppression( + "W92", + "This AWS Lambda Function does not require reserved concurrency", + ), + ], + ) + + self._send_anonymous_usage_data = CfnCondition( + self, + "SendAnonymousUsageData", + expression=Fn.condition_equals( + Fn.find_in_map("Solution", "Data", "SendAnonymousUsageData"), "Yes" + ), + ) + self._send_anonymous_usage_data.override_logical_id("SendAnonymousUsageData") + + properties = { + "ServiceToken": self._metrics_function.function_arn, + "Solution": self.node.try_get_context("SOLUTION_NAME"), + "Version": self.node.try_get_context("VERSION"), + "Region": Aws.REGION, + **metrics, + } + self.solution_metrics = CfnResource( + self, + "SolutionMetricsAnonymousData", + type="Custom::AnonymousData", + properties=properties, + ) + self.solution_metrics.override_logical_id("SolutionMetricsAnonymousData") + self.solution_metrics.cfn_options.condition = self._send_anonymous_usage_data diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/metrics.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/metrics.py new file mode 100644 index 0000000..0696aa1 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/metrics.py @@ -0,0 +1,81 @@ +# ##################################################################################################################### +# 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 logging +import uuid +from datetime import datetime +from os import getenv + +import requests +from crhelper import CfnResource + +logger = logging.getLogger(__name__) +helper = CfnResource(log_level=getenv("LOG_LEVEL", "WARNING")) +METRICS_ENDPOINT = "https://metrics.awssolutionsbuilder.com/generic" + + +def _sanitize_data(event): + resource_properties = event["ResourceProperties"] + # Remove ServiceToken (lambda arn) to avoid sending AccountId + resource_properties.pop("ServiceToken", None) + resource_properties.pop("Resource", None) + + # Solution ID and unique ID are sent separately + resource_properties.pop("Solution", None) + resource_properties.pop("UUID", None) + + # Add some useful fields related to stack change + resource_properties["CFTemplate"] = ( + event["RequestType"] + "d" + ) # Created, Updated, or Deleted + + return resource_properties + + +@helper.create +@helper.update +@helper.delete +def send_metrics(event, _): + resource_properties = event["ResourceProperties"] + random_id = event.get("PhysicalResourceId", str(uuid.uuid4())) + helper.Data["UUID"] = random_id + + try: + headers = {"Content-Type": "application/json"} + payload = { + "Solution": resource_properties["Solution"], + "UUID": random_id, + "TimeStamp": datetime.utcnow().isoformat(), + "Data": _sanitize_data(event), + } + + logger.info(f"Sending payload: {payload}") + response = requests.post(METRICS_ENDPOINT, json=payload, headers=headers) + logger.info( + f"Response from metrics endpoint: {response.status_code} {response.reason}" + ) + if "stackTrace" in response.text: + logger.exception("Error submitting usage data: %s" % response.text) + # raise when there is an HTTP error (non success code) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + logger.exception(f"Could not send usage data: {exc}") + except Exception as exc: + logger.exception(f"Unknown error when trying to send usage data: {exc}") + + return random_id + + +def handler(event, context): + helper(event, context) # pragma: no cover diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/requirements.txt b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/requirements.txt new file mode 100644 index 0000000..7962f8b --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/cfn_custom_resources/solutions_metrics/src/custom_resources/requirements.txt @@ -0,0 +1,2 @@ +requests==2.24.0 +crhelper==2.0.6 \ No newline at end of file diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/bundling.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/bundling.py new file mode 100644 index 0000000..e7465cc --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/bundling.py @@ -0,0 +1,117 @@ +# ##################################################################################################################### +# 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 logging +import shutil +import subprocess +from pathlib import Path +from typing import Union, Dict, Optional + +import jsii +from aws_cdk.core import ILocalBundling, BundlingOptions + +from aws_solutions.cdk.helpers import copytree + +logger = logging.getLogger("cdk-helper") + + +class UnsupportedBuildEnvironment(Exception): + pass + + +@jsii.implements(ILocalBundling) +class SolutionsJavaBundling: + """This interface allows AWS Solutions to package lambda functions for Java without the use of Docker""" + + def __init__( + self, + to_bundle: Path, + gradle_task: str, + distribution_path: Path, + gradle_test: Optional[str] = None, + ): + self.to_bundle = to_bundle + self.gradle_task = gradle_task + self.gradle_test = gradle_test + self.distribution_path = distribution_path + + def try_bundle(self, output_dir: str, options: BundlingOptions) -> bool: + source = Path(self.to_bundle).absolute() + + is_gradle_build = (source / "gradlew").exists() + if not is_gradle_build: + raise UnsupportedBuildEnvironment("please use a gradle project") + + # Run Tests + if self.gradle_test: + self._invoke_local_command( + name="gradle", + command=["./gradlew", self.gradle_test], + cwd=source, + ) + + # Run Build + self._invoke_local_command( + name="gradle", + command=["./gradlew", self.gradle_task], + cwd=source, + ) + + # if the distribution path is a path - it should only contain one jar or zip + if self.distribution_path.is_dir(): + children = [child for child in self.distribution_path.iterdir()] + if len(children) != 1: + raise ValueError( + "if the distribution path is a path it should only contain one jar or zip file" + ) + if children[0].suffix not in (".jar", ".zip"): + raise ValueError( + "the distribution path does not include a single .jar or .zip file" + ) + copytree(self.distribution_path, output_dir) + elif self.distribution_path.is_file(): + suffix = self.distribution_path.suffix + if suffix not in (".jar", ".zip"): + raise ValueError("the distribution file is not a .zip or .jar file") + shutil.copy(self.distribution_path, output_dir) + + return True + + def _invoke_local_command( + self, + name, + command, + env: Union[Dict, None] = None, + cwd: Union[str, Path, None] = None, + return_stdout: bool = False, + ): + + cwd = Path(cwd) + rv = "" + + with subprocess.Popen( + command, + shell=False, + stdout=subprocess.PIPE, + universal_newlines=True, + cwd=cwd, + env=env, + ) as p: + for line in p.stdout: + logger.info("%s %s: %s" % (self.to_bundle.name, name, line.rstrip())) + if return_stdout: + rv += line + + if p.returncode != 0: + raise subprocess.CalledProcessError(p.returncode, p.args) + + return rv.strip() diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/function.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/function.py new file mode 100644 index 0000000..f7a24af --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/java/function.py @@ -0,0 +1,117 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### +from pathlib import Path +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_lambda import Function, Runtime, RuntimeFamily, Code +from aws_cdk.core import ( + Construct, + BundlingOptions, + BundlingDockerImage, + BundlingOutput, + Aws, +) + +from aws_solutions.cdk.aws_lambda.java.bundling import SolutionsJavaBundling + + +class SolutionsJavaFunction(Function): + """This is similar to aws-cdk/aws-lambda-python, however it handles local building of Java Lambda Functions""" + + def __init__( + self, # NOSONAR + scope: Construct, + construct_id: str, + project_path: Path, + distribution_path: str, + gradle_task: str, + gradle_test: Optional[str] = None, + **kwargs, + ): + self.scope = scope + self.construct_id = construct_id + self.project_path = project_path + self.gradle_task = gradle_task + self.gradle_test = gradle_test + + if not project_path.is_dir(): + raise ValueError( + f"project_path {project_path} must be a directory, not a file" + ) + + # create default least privileged role for this function unless a role is passed + if not kwargs.get("role"): + kwargs["role"] = self._create_role() + + # Java 11 is the default runtime (Lambda supports 8/ 11) + if not kwargs.get("runtime"): + kwargs["runtime"] = Runtime.JAVA_11 + + if kwargs["runtime"].family != RuntimeFamily.JAVA: + raise ValueError( + f"SolutionsJavaFunction must use a Java runtime ({kwargs['runtime']} was provided)" + ) + + # This Construct will handle the creation of the 'code' parameter + if kwargs.get("code"): + raise ValueError( + f"SolutionsJavaFunction expects a Path `project_path` (python file) and `function` (function in the entrypoint for AWS Lambda to invoke)" + ) + + bundling = SolutionsJavaBundling( + to_bundle=project_path, + gradle_task=gradle_task, + gradle_test=gradle_test, + distribution_path=distribution_path, + ) + + kwargs["code"] = Code.from_asset( + path=str(project_path), + bundling=BundlingOptions( + image=BundlingDockerImage.from_registry("scratch"), # NOT USED + command=["NOT-USED"], + entrypoint=["NOT-USED"], + local=bundling, + output_type=BundlingOutput.ARCHIVED, + ), + ) + super().__init__(scope, construct_id, **kwargs) + + def _create_role(self) -> iam.Role: + """ + Build a role that allows an AWS Lambda Function to log to CloudWatch + :param name: The name of the role. The final name will be "{name}-Role" + :return: aws_cdk.aws_iam.Role + """ + return iam.Role( + self.scope, + f"{self.construct_id}-Role", + assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), + inline_policies={ + "LambdaFunctionServiceRolePolicy": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + actions=[ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + resources=[ + f"arn:{Aws.PARTITION}:logs:{Aws.REGION}:{Aws.ACCOUNT_ID}:log-group:/aws/lambda/*" + ], + ) + ] + ) + }, + ) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/bundling.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/bundling.py new file mode 100644 index 0000000..d78785b --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/bundling.py @@ -0,0 +1,225 @@ +# ##################################################################################################################### +# 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 importlib.util +import logging +import os +import platform +import subprocess +from pathlib import Path +from typing import Dict, Union + +import jsii +from aws_cdk.aws_lambda import Runtime +from aws_cdk.core import ILocalBundling, BundlingOptions + +from aws_solutions.cdk.helpers import copytree + +DEFAULT_RUNTIME = Runtime.PYTHON_3_7 +BUNDLER_DEPENDENCIES_CACHE = "/var/dependencies" +REQUIREMENTS_TXT_FILE = "requirements.txt" +REQUIREMENTS_PIPENV_FILE = "Pipfile" +REQUIREMENTS_POETRY_FILE = "pyproject.toml" + + +logger = logging.getLogger("cdk-helper") + + +class SolutionsPythonBundlingException(Exception): + pass + + +@jsii.implements(ILocalBundling) +class SolutionsPythonBundling: + """This interface allows AWS Solutions to package lambda functions without the use of Docker""" + + def __init__(self, to_bundle, libraries, install_path=""): + self.to_bundle = to_bundle + self.libraries = libraries + self.install_path = install_path + + @property + def platform_supports_bundling(self): + os_platform = platform.system() + os_platform_can_bundle = os_platform in ["Darwin", "Linux"] + logger.info( + "local bundling %s supported for %s" + % ("is" if os_platform_can_bundle else "is not", os_platform) + ) + return os_platform_can_bundle + + def try_bundle(self, output_dir: str, options: BundlingOptions) -> bool: + if not self.platform_supports_bundling: + raise SolutionsPythonBundlingException( + "this platform does not support bundling" + ) + + source = Path(self.to_bundle).absolute() + + # copy source + copytree(source, output_dir) + + # copy libraries + for lib in self.libraries: + lib_source = Path(lib).absolute() + lib_dest = Path(output_dir).joinpath(lib.name) + copytree(lib_source, lib_dest) + + try: + self._local_bundle_with_poetry(output_dir) + self._local_bundle_with_pipenv(output_dir) + self._local_bundle_with_pip(output_dir) + except subprocess.CalledProcessError as cpe: + raise SolutionsPythonBundlingException( + f"local bundling was tried but failed: {cpe}" + ) + + return True + + def _invoke_local_command( + self, + name, + command, + save_stdout: Path = None, + env: Union[Dict, None] = None, + cwd: Union[str, Path, None] = None, + ): + if save_stdout and save_stdout.exists(): + raise SolutionsPythonBundlingException( + f"{save_stdout} already exists - abandoning" + ) + + if save_stdout: + save_file = open(save_stdout, "w") + else: + save_file = None + + cwd = Path(cwd) + + with subprocess.Popen( + command, + shell=False, + stdout=subprocess.PIPE, + universal_newlines=True, + cwd=cwd, + env=env, + ) as p: + for line in p.stdout: + logger.info("%s %s: %s" % (self.to_bundle.name, name, line.rstrip())) + if save_file: + save_file.write(line) + + if save_file: + save_file.close() + + if p.returncode != 0: + raise subprocess.CalledProcessError(p.returncode, p.args) + + def validate_requirements_file(self, output_dir): + requirements_file = Path(output_dir) / REQUIREMENTS_TXT_FILE + with open(requirements_file, "r") as requirements: + for requirement in requirements: + if requirement.lstrip().startswith("-e"): + raise SolutionsPythonBundlingException( + "ensure no requirements are flagged as editable. if editable requirements are required, break down your requirements into requirements.txt and requirements-dev.txt" + ) + + def _source_file_exists(self, name, output_dir): + source_file = Path(output_dir) / name + exists = source_file.exists() + logger.info("%s file %s found" % (name, "was" if exists else "was not")) + return exists + + def _required_package_exists(self, package): + if not importlib.util.find_spec(package): + missing_package = ( + f"required package {package} was not installed - please install it" + ) + logger.warning(missing_package) + raise SolutionsPythonBundlingException(missing_package) + return True + + def _local_bundle_with_pip(self, output_dir): + if not self._source_file_exists(REQUIREMENTS_TXT_FILE, output_dir): + logger.info("no pip bundling to perform") + return + + self._required_package_exists("pip") + self.validate_requirements_file(output_dir) + + requirements_build_path = Path(output_dir).joinpath(self.install_path) + command = [ + "pip", + "install", + "-t", + str(requirements_build_path), + "-r", + str(Path(output_dir) / REQUIREMENTS_TXT_FILE), + "--use-feature=in-tree-build", + ] + self._invoke_local_command("pip", command, cwd=self.to_bundle) + + def _local_bundle_with_pipenv(self, output_dir): + if not self._source_file_exists(REQUIREMENTS_PIPENV_FILE, output_dir): + return # no Pipenv file found - do not bundle with Pipenv + + if self._source_file_exists(REQUIREMENTS_TXT_FILE, output_dir): + logger.error( + "both a Pipenv and requirements.txt file were found - use one or the other" + ) + raise SolutionsPythonBundlingException( + "confusing Python package bundling - use one of requirements.txt (pip), pipenv (Pipenv) or pyproject.toml (poetry)" + ) + + self._required_package_exists("pipenv") + + command = ["pipenv", "--bare", "lock", "--no-header", "-r"] + env = os.environ.copy() + env.update( + { + "PIPENV_VERBOSITY": "-1", + "PIPENV_CLEAR": "true", + } + ) + self._invoke_local_command( + "pipenv", + command, + save_stdout=Path(output_dir) / REQUIREMENTS_TXT_FILE, + env=env, + cwd=output_dir, + ) + + def _local_bundle_with_poetry(self, output_dir): + if not self._source_file_exists(REQUIREMENTS_POETRY_FILE, output_dir): + return # no pyproject.toml file found - do not bundle with poetry + + if self._source_file_exists(REQUIREMENTS_TXT_FILE, output_dir): + logger.error( + "both a pyproject.toml and requirements.txt file were found - use one or the other" + ) + raise SolutionsPythonBundlingException( + "confusing Python package bundling - use one of requirements.txt (pip), pipenv (Pipenv) or pyproject.toml (poetry)" + ) + + self._required_package_exists("poetry") + + command = [ + "poetry", + "export", + "--with-credentials", + "--format", + REQUIREMENTS_TXT_FILE, + "--output", + str(Path(output_dir) / REQUIREMENTS_TXT_FILE), + ] + self._invoke_local_command("poetry", command, cwd=output_dir) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/function.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/function.py new file mode 100644 index 0000000..39ccc53 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/function.py @@ -0,0 +1,189 @@ +# ##################################################################################################################### +# 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 hashlib +import os +from pathlib import Path +from typing import List, Union + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_lambda import Function, Runtime, RuntimeFamily, Code +from aws_cdk.core import ( + Construct, + AssetHashType, + BundlingOptions, + BundlingDockerImage, + Aws, +) + +from aws_solutions.cdk.aws_lambda.python.bundling import SolutionsPythonBundling + +DEFAULT_RUNTIME = Runtime.PYTHON_3_7 +DEPENDENCY_EXCLUDES = ["*.pyc"] + + +class DirectoryHash: + # fmt: off + _hash = hashlib.sha1() # NOSONAR - safe to hash; side-effect of collision is to create new bundle + # fmt: on + + @classmethod + def hash(cls, *directories: Path): + DirectoryHash._hash = hashlib.sha1() # NOSONAR - safe to hash; see above + if isinstance(directories, Path): + directories = [directories] + for directory in sorted(directories): + DirectoryHash._hash_dir(str(directory.absolute())) + return DirectoryHash._hash.hexdigest() + + @classmethod + def _hash_dir(cls, directory: Path): + for path, dirs, files in os.walk(directory): + for file in sorted(files): + DirectoryHash._hash_file(Path(path) / file) + for directory in sorted(dirs): + DirectoryHash._hash_dir(str((Path(path) / directory).absolute())) + break + + @classmethod + def _hash_file(cls, file: Path): + with file.open("rb") as f: + while True: + block = f.read(2 ** 10) + if not block: + break + DirectoryHash._hash.update(block) + + +class SolutionsPythonFunction(Function): + """This is similar to aws-cdk/aws-lambda-python, however it handles local bundling""" + + def __init__( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + construct_id: str, + entrypoint: Path, + function: str, + libraries: Union[List[Path], Path, None] = None, + **kwargs, + ): + self.scope = scope + self.construct_id = construct_id + self.source_path = entrypoint.parent + + # validate source path + if not self.source_path.is_dir(): + raise ValueError( + f"entrypoint {entrypoint} must not be a directory, but rather a .py file" + ) + + # validate libraries + self.libraries = libraries or [] + self.libraries = ( + self.libraries if isinstance(self.libraries, list) else [self.libraries] + ) + for lib in self.libraries: + if lib.is_file(): + raise ValueError( + f"library {lib} must not be a file, but rather a directory" + ) + + # create default least privileged role for this function unless a role is passed + if not kwargs.get("role"): + kwargs["role"] = self._create_role() + + # python 3.7 is selected to support custom resources and inline code + if not kwargs.get("runtime"): + kwargs["runtime"] = DEFAULT_RUNTIME + + # validate that the user is using a python runtime for AWS Lambda + if kwargs["runtime"].family != RuntimeFamily.PYTHON: + raise ValueError( + f"SolutionsPythonFunction must use a Python runtime ({kwargs['runtime']} was provided)" + ) + + # build the handler based on the entrypoint Path and function name + if kwargs.get("handler"): + raise ValueError( + f"SolutionsPythonFunction expects a Path `entrypoint` (python file) and `function` (function in the entrypoint for AWS Lambda to invoke)" + ) + else: + kwargs["handler"] = f"{entrypoint.stem}.{function}" + + # build the code based on the entrypoint Path + if kwargs.get("code"): + raise ValueError( + f"SolutionsPythonFunction expects a Path `entrypoint` (python file) and `function` (function in the entrypoint for AWS Lambda to invoke)" + ) + + bundling = SolutionsPythonBundling( + self.source_path, + self.libraries, + ) + + kwargs["code"] = self._get_code(bundling, runtime=kwargs["runtime"]) + + # initialize the parent Function + super().__init__(scope, construct_id, **kwargs) + + def _get_code(self, bundling: SolutionsPythonBundling, runtime: Runtime) -> Code: + # try to create the code locally - if this fails, try using Docker + code_parameters = { + "path": str(self.source_path), + "asset_hash_type": AssetHashType.CUSTOM, + "asset_hash": DirectoryHash.hash(self.source_path, *self.libraries), + "exclude": DEPENDENCY_EXCLUDES, + } + + # to enable docker only bundling, use image=self._get_bundling_docker_image(bundling, runtime=runtime) + code = Code.from_asset( + bundling=BundlingOptions( + image=BundlingDockerImage.from_registry( + "scratch" + ), # NOT USED - FOR NOW ALL BUNDLING IS LOCAL + command=["NOT-USED"], + entrypoint=["NOT-USED"], + local=bundling, + ), + **code_parameters, + ) + + return code + + def _create_role(self) -> iam.Role: + """ + Build a role that allows an AWS Lambda Function to log to CloudWatch + :param name: The name of the role. The final name will be "{name}-Role" + :return: aws_cdk.aws_iam.Role + """ + return iam.Role( + self.scope, + f"{self.construct_id}-Role", + assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), + inline_policies={ + "LambdaFunctionServiceRolePolicy": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + actions=[ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + resources=[ + f"arn:{Aws.PARTITION}:logs:{Aws.REGION}:{Aws.ACCOUNT_ID}:log-group:/aws/lambda/*" + ], + ) + ] + ) + }, + ) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/layer.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/layer.py new file mode 100644 index 0000000..5051c83 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/aws_lambda/python/layer.py @@ -0,0 +1,79 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from pathlib import Path +from typing import Union, List + +from aws_cdk.aws_lambda import LayerVersion, Code +from aws_cdk.core import Construct, BundlingOptions, BundlingDockerImage, AssetHashType + +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonBundling + + +class SolutionsPythonLayerVersion(LayerVersion): + """Handle local packaging of layer versions""" + + def __init__( + self, + scope: Construct, + construct_id: str, + requirements_path: Path, + libraries: Union[List[Path], None] = None, + **kwargs, + ): # NOSONAR + self.scope = scope + self.construct_id = construct_id + self.requirements_path = requirements_path + + # validate requirements path + if not self.requirements_path.is_dir(): + raise ValueError( + f"requirements_path {self.requirements_path} must not be a file, but rather a directory containing Python requirements in a requirements.txt file, pipenv format or poetry format" + ) + + libraries = [] if not libraries else libraries + for lib in libraries: + if lib.is_file(): + raise ValueError( + f"library {lib} must not be a file, but rather a directory" + ) + + bundling = SolutionsPythonBundling( + self.requirements_path, libraries=libraries, install_path="python" + ) + + kwargs["code"] = self._get_code(bundling) + + # initialize the LayerVersion + super().__init__(scope, construct_id, **kwargs) + + def _get_code(self, bundling: SolutionsPythonBundling) -> Code: + # create the layer version locally + code_parameters = { + "path": str(self.requirements_path), + "asset_hash_type": AssetHashType.SOURCE, + } + + code = Code.from_asset( + bundling=BundlingOptions( + image=BundlingDockerImage.from_registry( + "scratch" + ), # NEVER USED - FOR NOW ALL BUNDLING IS LOCAL + command=["not_used"], + entrypoint=["not_used"], + local=bundling, + ), + **code_parameters, + ) + + return code diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/cfn_nag.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/cfn_nag.py new file mode 100644 index 0000000..84c7c28 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/cfn_nag.py @@ -0,0 +1,58 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from dataclasses import dataclass +from typing import List + +import jsii +from aws_cdk.core import CfnResource, IAspect, IConstruct + + +@dataclass +class CfnNagSuppression: + rule_id: str + reason: str + + +def add_cfn_nag_suppressions( + resource: CfnResource, suppressions: List[CfnNagSuppression] +): + resource.add_metadata( + "cfn_nag", + { + "rules_to_suppress": [ + {"id": suppression.rule_id, "reason": suppression.reason} + for suppression in suppressions + ] + }, + ) + + +@jsii.implements(IAspect) +class CfnNagSuppressAll: + """Suppress certain cfn_nag warnings that can be ignored by this solution""" + + def __init__(self, suppress: List[CfnNagSuppression], resource_type: str): + self.suppressions = suppress + self.resource_type = resource_type + + def visit(self, node: IConstruct): + if "is_cfn_element" in dir(node) and node.is_cfn_element(node): + if getattr(node, "cfn_resource_type", None) == self.resource_type: + add_cfn_nag_suppressions(node, self.suppressions) + + elif "is_cfn_element" in dir(node.node.default_child) and ( + getattr(node.node.default_child, "cfn_resource_type", None) + == self.resource_type + ): + add_cfn_nag_suppressions(node.node.default_child, self.suppressions) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/context.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/context.py new file mode 100644 index 0000000..066ad41 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/context.py @@ -0,0 +1,84 @@ +# ##################################################################################################################### +# 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 json +import logging +from functools import wraps +from os import environ +from pathlib import Path +from typing import Union + +ARGUMENT_ERROR = "functions decorated with `with_cdk_context` can only accept one dictionary argument - the additional context overrides to use" + +logger = logging.getLogger("cdk-helper") + + +class SolutionContext: + def __init__(self, cdk_json_path: Union[None, Path] = None): + self.cdk_json_path = cdk_json_path + self.context = self._load_cdk_context() + + def requires( # NOSONAR - higher cognitive complexity allowed + self, context_var_name, context_var_value=None + ): + context = self.context + + def cdk_context_decorator(f): + @wraps(f) + def wrapper(*args): + # validate function arguments + if len(args) > 1: + raise ValueError(ARGUMENT_ERROR) + if len(args) == 1 and not isinstance(args[0], dict): + raise TypeError(ARGUMENT_ERROR) + + if len(args) == 0: + args = (context,) + + # override the CDK context as required + if len(args) == 1: + context.update(args[0]) + + env_context_var = environ.get(context_var_name) + if env_context_var: + context[context_var_name] = env_context_var + elif context_var_name and context_var_value: + context[context_var_name] = context_var_value + + if not context.get(context_var_name): + raise ValueError( + f"Missing cdk.json context variable or environment variable for {context_var_name}." + ) + + args = (context,) + + return f(*args) + + return wrapper + + return cdk_context_decorator + + def _load_cdk_context(self): + """Load context from cdk.json""" + if not self.cdk_json_path: + return {} + + try: + with open(self.cdk_json_path, "r") as f: + config = json.loads(f.read()) + except FileNotFoundError: + logger.warning(f"{self.cdk_json_path} not found, using empty context!") + return {} + context = config.get("context", {}) + return context diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/__init__.py new file mode 100644 index 0000000..bae5553 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/__init__.py @@ -0,0 +1,14 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_solutions.cdk.helpers.copytree import copytree, ignore_globs diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/copytree.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/copytree.py new file mode 100644 index 0000000..b162f73 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/copytree.py @@ -0,0 +1,59 @@ +# ##################################################################################################################### +# 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 os +import shutil +from pathlib import Path + + +def ignore_globs(*globs): + """Function that can be used as copytree() ignore parameter. + + Patterns is a sequence of glob-style patterns + that are used to exclude files""" + + # globs = globs + tuple([glob[:-2] for glob in globs if glob.endswith('/*')]) # ignore folders + + def _ignore_globs(path, names): + ignored_names = [] + paths = [Path(os.path.join(path, name)).resolve() for name in names] + for pattern in globs: + for i, p in enumerate(paths): + if p.match(pattern): + ignored_names.append(names[i]) + return set(ignored_names) + + return _ignore_globs + + +def copytree(src, dst, symlinks=False, ignore=None): + if ignore: + ignore.extend([ignored[:-2] for ignored in ignore if ignored.endswith("/*")]) + else: + ignore = [] + + if not os.path.exists(dst): + os.makedirs(dst) + + for item in os.listdir(src): + s = os.path.join(src, item) + d = os.path.join(dst, item) + + # ignore full directories upfront + if any(Path(s).match(ignored) for ignored in ignore): + continue + + if os.path.isdir(s): + shutil.copytree(s, d, symlinks, ignore=ignore_globs(*ignore)) + else: + shutil.copy2(s, d) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/loader.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/loader.py new file mode 100644 index 0000000..7faea22 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/loader.py @@ -0,0 +1,94 @@ +# ##################################################################################################################### +# 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 importlib +import json +import logging +from functools import wraps +from pathlib import Path + +logger = logging.getLogger("cdk-helper") + + +class CDKLoaderException(Exception): + pass + + +def log_error(error): + logger.error(error) + raise CDKLoaderException(error) + + +def _cdk_json_present(func): + @wraps(func) + def cdk_json_present(cdk_app_path: Path, cdk_app_name): + app_path = cdk_app_path.parent + cdk_json_dict = {} + if not Path(app_path / "cdk.json").exists(): + log_error(f"please ensure a cdk.json is present at {app_path}") + + try: + cdk_json_dict = json.loads(Path(app_path / "cdk.json").read_text()) + except ValueError as exc: + log_error(f"failed to parse cdk.json: {exc}") + + cdk_app = cdk_json_dict.get("app") + if not cdk_app: + log_error(f"failed to find `app` in cdk.json") + + if "python3" not in cdk_app: + log_error( + f"this helper only supports python3 CDK apps at this time - yours was declared as {cdk_app}" + ) + + return func(cdk_app_path, cdk_app_name) + + return cdk_json_present + + +@_cdk_json_present +def load_cdk_app(cdk_app_path, cdk_app_name): + """ + Load a CDK app from a folder path (dynamically) + :param cdk_app_path: The full path of the CDK app to load + :param cdk_app_name: The module path (starting from cdk_app_path) to find the function returning synth() + :return: + """ + + try: + (cdk_app_name, cdk_app_entrypoint) = cdk_app_name.split(":") + except ValueError: + log_error("please provide your `cdk_app_name` as path.to.cdk:function_name") + + if not cdk_app_path.exists(): + log_error(f"could not find `{cdk_app_name}` (please use a full path)") + + spec = importlib.util.spec_from_file_location(cdk_app_name, cdk_app_path) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except Exception as exc: + log_error(f"could not load `{cdk_app_entrypoint}` in `{cdk_app_name}`: {exc}") + + try: + cdk_function = getattr(module, cdk_app_entrypoint) + except AttributeError as exc: + log_error( + f"could not find CDK entrypoint `{cdk_app_entrypoint}` in `{cdk_app_name}`" + ) + + logger.info(f"loaded AWS CDK app from {cdk_app_path}") + logger.info( + f"loaded AWS CDK app at {cdk_app_name}, entrypoint is {cdk_app_entrypoint}" + ) + return cdk_function diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/logger.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/logger.py new file mode 100644 index 0000000..5351235 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/helpers/logger.py @@ -0,0 +1,35 @@ +# ##################################################################################################################### +# 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 logging + + +class Logger: + """Set up a logger fo this package""" + + @classmethod + def get_logger(cls, name: str) -> logging.Logger: + """ + Gets the current logger for this package + :param name: the name of the logger + :return: the logger + """ + logger = logging.getLogger(name) + if not len(logger.handlers): + logger.setLevel(logging.INFO) + handler = logging.StreamHandler() + formatter = logging.Formatter("[%(levelname)s]\t%(name)s\t%(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.propagate = False + return logger diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/interfaces.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/interfaces.py new file mode 100644 index 0000000..c3a7a2e --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/interfaces.py @@ -0,0 +1,108 @@ +# ##################################################################################################################### +# 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 logging +from dataclasses import dataclass +from typing import Union, List + +import jsii +from aws_cdk.core import ( + ITemplateOptions, + Stack, + NestedStack, + CfnParameter, +) + +logger = logging.getLogger("cdk-helper") + + +@dataclass +class _TemplateParameter: + """Stores information about a CloudFormation parameter, its label (description) and group""" + + name: str + label: str + group: str + + +class TemplateOptionsException(Exception): + pass + + +@jsii.implements(ITemplateOptions) +class TemplateOptions: + """Helper class for setting up template CloudFormation parameter groups, labels and solutions metadata""" + + _metadata = {} + + def __init__( + self, + stack: Union[Stack, NestedStack], + construct_id: str, + description: str, + filename: str, + ): + self.stack = stack + self.filename = filename + self._parameters: List[_TemplateParameter] = [] + self.stack.template_options.description = description + self.stack.template_options.metadata = self.metadata + + self._metadata = self._get_metadata() + + if not filename.endswith(".template"): + raise TemplateOptionsException("template filenames must end with .template") + + # if this stack is a nested stack, record its CDK ID in the parent stack's resource to it + if getattr(stack, "nested_stack_resource"): + stack.nested_stack_resource.add_metadata( + "aws:solutions:templateid", construct_id + ) + stack.nested_stack_resource.add_metadata( + "aws:solutions:templatename", filename + ) + + @property + def metadata(self) -> dict: + return self._metadata + + def _get_metadata(self) -> dict: + parameter_groups = list( + set([parameter.group for parameter in self._parameters]) + ) + metadata = { + "AWS::CloudFormation::Interface": { + "ParameterGroups": [ + { + "Label": {"default": parameter_group}, + "Parameters": [ + parameter.name + for parameter in self._parameters + if parameter.group == parameter_group + ], + } + for parameter_group in parameter_groups + ], + "ParameterLabels": { + parameter.name: {"default": parameter.label} + for parameter in self._parameters + }, + }, + "aws:solutions:templatename": self.filename, + } + self.stack.template_options.metadata = metadata + return metadata + + def add_parameter(self, parameter: CfnParameter, label: str, group: str): + self._parameters.append(_TemplateParameter(parameter.logical_id, label, group)) + self._metadata = self._get_metadata() diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/mappings.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/mappings.py new file mode 100644 index 0000000..fee788a --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/mappings.py @@ -0,0 +1,54 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_cdk.core import Construct, CfnMapping + + +class Mappings: + def __init__( + self, + parent: Construct, + solution_id: str, + send_anonymous_usage_data: bool = True, + quicksight_template_arn: bool = False, + ): + self.parent = parent + + # Track the solution mapping (ID, version, anonymous usage data) + self.solution_mapping = CfnMapping( + parent, + "Solution", + mapping={ + "Data": { + "ID": solution_id, + "Version": "%%SOLUTION_VERSION%%", + "SendAnonymousUsageData": "Yes" + if send_anonymous_usage_data + else "No", + } + }, + ) + + # track the s3 bucket, key prefix and (optional) quicksight template source + general = { + "S3Bucket": "%%BUCKET_NAME%%", + "KeyPrefix": "%%SOLUTION_NAME%%/%%SOLUTION_VERSION%%", + } + if quicksight_template_arn: + general["QuickSightSourceTemplateArn"] = "%%QUICKSIGHT_SOURCE%%" + + self.source_mapping = CfnMapping( + parent, + "SourceCode", + mapping={"General": general}, + ) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/build_s3_cdk_dist.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/build_s3_cdk_dist.py new file mode 100644 index 0000000..dc74a13 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/scripts/build_s3_cdk_dist.py @@ -0,0 +1,400 @@ +#!/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 os +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +import boto3 +import botocore +import click + +from aws_solutions.cdk.helpers import copytree +from aws_solutions.cdk.helpers.loader import load_cdk_app +from aws_solutions.cdk.helpers.logger import Logger +from aws_solutions.cdk.tools import Cleaner + +logger = Logger.get_logger("cdk-helper") + + +class PathPath(click.Path): + def convert(self, value, param, ctx): + return Path(super().convert(value, param, ctx)) + + +@dataclass +class BuildEnvironment: + source_bucket_name: str = field(default="") + solution_name: str = field(default="") + version_code: str = field(default="") + template_dir: str = field(default_factory=os.getcwd, init=False) + template_dist_dir: str = field(init=False, repr=False) + build_dir: str = field(init=False, repr=False) + build_dist_dir: str = field(init=False, repr=False) + source_dir: str = field(init=False, repr=False) + infrastructure_dir: str = field(init=False, repr=False) + + def __post_init__(self): + self.template_dist_dir = os.path.join(self.template_dir, "global-s3-assets") + self.build_dir = os.path.join(self.template_dir, "build-s3-assets") + self.build_dist_dir = os.path.join(self.template_dir, "regional-s3-assets") + self.source_dir = os.path.normpath( + os.path.join(self.template_dir, os.pardir, "source") + ) + self.infrastructure_dir = os.path.join(self.source_dir, "infrastructure") + self.open_source_dir = os.path.join(self.template_dir, "open-source") + self.github_dir = os.path.normpath( + os.path.join(self.template_dir, os.pardir, ".github") + ) + + logger.debug("build environment template directory: %s" % self.template_dir) + logger.debug( + "build environment template distribution directory: %s" + % self.template_dist_dir + ) + logger.debug("build environment build directory: %s" % self.build_dir) + logger.debug( + "build environment build distribution directory: %s" % self.build_dist_dir + ) + logger.debug("build environment source directory: %s" % self.source_dir) + logger.debug( + "build environment infrastructure directory: %s" % self.infrastructure_dir + ) + logger.debug("open source dir: %s" % self.open_source_dir) + + def clean_for_scan(self): + """Clean up the build environment partially to optimize code scan in next build stage""" + cleaner = Cleaner() + cleaner.cleanup_source(self.source_dir) + return cleaner + + def clean(self): + """Clean up the build environment""" + cleaner = self.clean_for_scan() + cleaner.clean_dirs(self.template_dist_dir, self.build_dir, self.build_dist_dir) + return cleaner + + def clean_for_open_source(self): + """Clean up the build environment for the open source build""" + cleaner = self.clean_for_scan() + cleaner.clean_dirs(self.open_source_dir) + return cleaner + + +class BaseAssetPackager: + """Shared commands across asset packagers""" + + local_asset_path = None + s3_asset_path = None + + def sync(self): + """Sync the assets packaged""" + if not self.local_asset_path: + raise ValueError("missing local asset path for sync") + if not self.s3_asset_path: + raise ValueError("missing s3 asset path for sync") + + self.check_bucket() + try: + with subprocess.Popen( + [ + "aws", + "s3", + "sync", + self.local_asset_path, + self.s3_asset_path, + "--no-progress", + "--acl", + "bucket-owner-full-control", + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) as p: + for line in p.stdout: + logger.info("s3 sync: %s" % line.strip()) + for line in p.stderr: + logger.error("s3 sync: %s" % line.strip()) + except FileNotFoundError: + logger.error("awscli is missing") + raise click.ClickException("--sync requires awscli to be installed") + if p.returncode != 0: + raise click.ClickException("--sync failed") + + def check_bucket(self) -> bool: + """Checks bucket ownership before sync""" + bucket = self.s3_asset_path.split("/")[2] + sts = boto3.client("sts") + account = sts.get_caller_identity()["Account"] + + s3 = boto3.client("s3") + try: + s3.head_bucket(Bucket=bucket, ExpectedBucketOwner=account) + except botocore.exceptions.ClientError as err: + status = err.response["ResponseMetadata"]["HTTPStatusCode"] + error = err.response["Error"]["Code"] + if status == 404: + logger.error("missing bucket: %s" % error) + elif status == 403: + logger.error("access denied - check bucket ownership: %s" % error) + else: + logger.exception("unknown error: %s" % error) + raise + return True + + +class RegionalAssetPackager(BaseAssetPackager): + """Used to package regional assets""" + + def __init__(self, build_env: BuildEnvironment, region="us-east-1"): + self.build_env = build_env + self.local_asset_path = build_env.build_dist_dir + self.s3_asset_path = f"s3://{build_env.source_bucket_name}-{region}/{build_env.solution_name}/{build_env.version_code}" + + def package(self): + logger.info("packaging regional assets") + + +class GlobalAssetPackager(BaseAssetPackager): + """Used to package global assets""" + + def __init__(self, build_env: BuildEnvironment): + self.build_env = build_env + self.local_asset_path = build_env.template_dist_dir + self.s3_asset_path = f"s3://{build_env.source_bucket_name}/{build_env.solution_name}/{build_env.version_code}" + + def package(self): + logger.info("packaging global assets") + + +def validate_version_code(ctx, param, value): + """ + Version codes are validated as semantic versions prefixed by a v, e.g. v1.2.3 + :param ctx: the click context + :param param: the click parameter + :param value: the parameter value + :return: the validated value + """ + re_semver = r"^v(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + if re.match(re_semver, value): + return value + else: + raise click.BadParameter( + "please specifiy major, minor and patch versions, e.g. v1.0.0" + ) + + +@click.group() +@click.option( + "--log-level", + help="The log level to use", + default="INFO", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), +) +@click.pass_context +def cli(ctx, log_level): + """This CLI helps to build and package your AWS Solution CDK application as CloudFormation templates""" + ctx.ensure_object(dict) + logger.setLevel(log_level) + + +@cli.command() +@click.pass_context +def clean_for_scan(ctx): + """Use this to partially clean generated build files to optimize code scan in next build stage""" + env = BuildEnvironment() + env.clean_for_scan() + + +@cli.command() +@click.pass_context +@click.option("--ignore", "-i", multiple=True) +@click.option("--solution-name", help="The name of the solution.", required=True) +def source_code_package(ctx, ignore, solution_name): + """Use this to build the source package folder and zip file""" + env = BuildEnvironment() + env.clean_for_open_source() + + # set up some default ignore directories + ignored = [ + "**/cdk.out/*", + "**/__pycache__/*", + "*.pyc", + "*.pyo", + "*.pyd", + "**/.gradle/*", + "**/.idea/*", + "**/.coverage/*", + "**/.pytest_cache/*", + "**/*.egg-info", + "**/__pycache__", + ] + ignored.extend(ignore) + + required_files = [ + "LICENSE.txt", + "NOTICE.txt", + "README.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + ".gitignore", + ] + + # copy source directory + try: + copytree( + env.source_dir, os.path.join(env.open_source_dir, "source"), ignore=ignored + ) + copytree(env.github_dir, os.path.join(env.open_source_dir, ".github")) + except FileNotFoundError: + raise click.ClickException( + "The solution requires a `source` folder and a `.github` folder" + ) + + # copy all required files + for name in required_files: + try: + shutil.copyfile( + Path(env.source_dir).parent / name, Path(env.open_source_dir) / name + ) + except FileNotFoundError: + raise click.ClickException( + f"The solution is missing the required file {name}" + ) + + # copy the required run-unit-tests.sh + (Path(env.open_source_dir) / "deployment").mkdir() + try: + shutil.copyfile( + Path(env.template_dir) / "run-unit-tests.sh", + Path(env.open_source_dir) / "deployment" / "run-unit-tests.sh", + ) + except FileNotFoundError: + raise click.ClickException( + f"The solution is missing deployment/run-unit-tests.sh" + ) + + shutil.make_archive( + base_name=os.path.join(env.template_dir, solution_name), + format="zip", + root_dir=os.path.join(env.open_source_dir), + logger=logger, + ) + + # finalize by deleting the open-source folder data and copying the zip file over + env.clean_for_open_source() + shutil.move( + os.path.join(env.template_dir, f"{solution_name}.zip"), env.open_source_dir + ) + + +@cli.command() +@click.pass_context +@click.option( + "--source-bucket-name", + help="Configure the bucket name of your target Amazon S3 distribution bucket. A randomized value is recommended. " + "You will also need to create an S3 bucket where the name is -. The solution's " + "CloudFormation template will expect the source code to be located in a bucket matching that name.", + required=True, +) +@click.option("--solution-name", help="The name of the solution.", required=True) +@click.option( + "--version-code", + help="The version of the package.", + required=True, + callback=validate_version_code, +) +@click.option( + "--cdk-app-path", + help="The CDK Python app path", + required=True, + type=PathPath(dir_okay=False), +) +@click.option( + "--cdk-app-entrypoint", + help="The CDK Python app entrypoint", + required=True, +) +@click.option( + "--sync", + help="Use this to sync your assets to the global and regional source-buckets.", + default=False, + is_flag=True, +) +@click.option( + "--region", + help="Use this flag to control which regional bucket to push your assets to", + default="us-east-1", +) +def deploy( + ctx, # NOSONAR (python:S107) - allow large number of method parameters + source_bucket_name, + solution_name, + version_code, + cdk_app_path, + cdk_app_entrypoint, + sync, + region, +): + """Runs the CDK build of the project, uploading assets as required.""" + + # load the cdk app dynamically + cdk = load_cdk_app( + cdk_app_path=cdk_app_path, + cdk_app_name=cdk_app_entrypoint, + ) + + # set up relevant directories and clean the build environment + env = BuildEnvironment( + source_bucket_name=source_bucket_name, + solution_name=solution_name, + version_code=version_code, + ) + + # clean up the build environment from previous builds before running this build + env.clean() + + # run cdk asset packaging + cdk( + { + "BUCKET_NAME": source_bucket_name, + "SOLUTION_NAME": solution_name, + "SOLUTION_VERSION": version_code, + "SOLUTIONS_ASSETS_REGIONAL": env.build_dist_dir, + "SOLUTIONS_ASSETS_GLOBAL": env.template_dist_dir, + } + ) + + # run regional asset packaging + rap = RegionalAssetPackager(env, region=region) + rap.package() + + # run global asset packaging + gap = GlobalAssetPackager(env) + gap.package() + + # sync as required + if sync: + rap.sync() + gap.sync() + + +if __name__ == "__main__": + cli() diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/stack.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/stack.py new file mode 100644 index 0000000..b85ed84 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/stack.py @@ -0,0 +1,64 @@ +# ##################################################################################################################### +# 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 re + +from aws_cdk.core import Stack, Construct + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.solutions_metrics import Metrics +from aws_solutions.cdk.interfaces import TemplateOptions +from aws_solutions.cdk.mappings import Mappings + +RE_SOLUTION_ID = re.compile(r"^SO\d+$") +RE_TEMPLATE_FILENAME = re.compile(r"^[a-z]+(?:-[a-z]+)*\.template$") # NOSONAR + + +def validate_re(name, value, regex: re.Pattern): + if regex.match(value): + return value + raise ValueError(f"{name} must match '{regex.pattern}") + + +def validate_solution_id(solution_id: str) -> str: + return validate_re("solution_id", solution_id, RE_SOLUTION_ID) + + +def validate_template_filename(template_filename: str) -> str: + return validate_re("template_filename", template_filename, RE_TEMPLATE_FILENAME) + + +class SolutionStack(Stack): + def __init__( + self, + scope: Construct, + construct_id: str, + description: str, + template_filename, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + self.metrics = {} + self.solution_id = self.node.try_get_context("SOLUTION_ID") + self.mappings = Mappings(self, solution_id=self.solution_id) + self.solutions_template_filename = validate_template_filename(template_filename) + self.solutions_template_options = TemplateOptions( + self, + construct_id=construct_id, + description=f"({self.solution_id}) {description}", + filename=template_filename, + ) + + def _prepare(self) -> None: + """Called before synthesis, this allows us to set metrics at the end of synthesis""" + self.metrics = Metrics(self, "Metrics", self.metrics) diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/synthesizers.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/synthesizers.py new file mode 100644 index 0000000..e7d7711 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/synthesizers.py @@ -0,0 +1,317 @@ +# ##################################################################################################################### +# 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 json +import logging +import os +import re +import shutil +from contextlib import suppress +from dataclasses import field, dataclass +from fileinput import FileInput +from pathlib import Path +from typing import List, Dict + +import jsii +from aws_cdk.core import IStackSynthesizer, DefaultStackSynthesizer, ISynthesisSession + +logger = logging.getLogger("cdk-helper") + + +@dataclass +class CloudFormationTemplate: + """Encapsulates the transformations that are required on a CDK generated CloudFormation template for AWS Solutions""" + + path: Path + contents: Dict = field(repr=False) + assets: Path = field(repr=False) + stack_name: str = field(repr=False, init=False) + cloud_assembly_path: Path = field(repr=False, init=False) + assets_global: List[Path] = field(repr=False, default_factory=list, init=False) + assets_regional: List[Path] = field(repr=False, default_factory=list, init=False) + global_asset_name: str = field(repr=False, init=False) + + def __post_init__(self): + self.cloud_assembly_path = self.path.parent + self.stack_name = self.path.stem.split(".")[0] + self.assets_global.append(self.path) + try: + self.global_asset_name = self.contents["Metadata"][ + "aws:solutions:templatename" + ] + except KeyError: + logger.warning( + "for nested stack support, you must provide a filename to TemplateOptions for each stack" + ) + + def delete_bootstrap_parameters(self): + """Remove the CDK bootstrap parameters, since this stack will not be bootstrapped""" + with suppress(KeyError): + del self.contents["Parameters"]["BootstrapVersion"] + + with suppress(KeyError): + if len(self.contents["Parameters"]) == 0: + del self.contents["Parameters"] + + with suppress(KeyError): + del self.contents["Rules"]["CheckBootstrapVersion"] + + with suppress(KeyError): + if len(self.contents["Rules"]) == 0: + del self.contents["Rules"] + + def delete_cdk_helpers(self): + """Remove the CDK bucket deployment helpers, since solutions don't have a bootstrap bucket.""" + to_delete = [] + for (resource_name, resource) in self.contents.get("Resources", {}).items(): + if "Custom::CDKBucketDeployment" in resource["Type"]: + to_delete.append(resource_name) + if "CDKBucketDeployment" in resource_name: + to_delete.append(resource_name) + for resource in to_delete: + logger.info(f"deleting resource {resource}") + del self.contents["Resources"][resource] + + def patch_nested(self): + """Patch nested stacks for S3 deployment compatibility""" + template_output_bucket = os.getenv( + "TEMPLATE_OUTPUT_BUCKET", + { + "Fn::FindInMap": [ # NOSONAR (python:S1192) - string for clarity + "SourceCode", + "General", + "S3Bucket", + ] + }, + ) + for (resource_name, resource) in self.contents.get("Resources", {}).items(): + resource_type = resource.get("Type") + if resource_type == "AWS::CloudFormation::Stack": + try: + nested_stack_filename = resource["Metadata"][ + "aws:solutions:templatename" + ] + except KeyError: + raise KeyError("nested stack missing required TemplateOptions") + + # update CloudFormation resource properties for S3Bucket and S3Key + # fmt: off + resource["Properties"]["TemplateURL"] = { + "Fn::Join": [ # NOSONAR (python:S1192) - string for clarity + "", + [ + "https://", + template_output_bucket, + ".s3.", + {"Ref": "AWS::URLSuffix"}, + "/", + { + "Fn::FindInMap": ["SourceCode", "General", "KeyPrefix"] # NOSONAR (python:S1192) - string for clarity + }, + "/", + nested_stack_filename, + ], + ] + } + # fmt: on + + def patch_lambda(self): + """Patch the lambda functions for S3 deployment compatibility""" + for (resource_name, resource) in self.contents.get("Resources", {}).items(): + resource_type = resource.get("Type") + if ( + resource_type == "AWS::Lambda::Function" + or resource_type == "AWS::Lambda::LayerVersion" + ): + logger.info(f"{resource_name} ({resource_type}) patching") + + # the key for S3Key for AWS::Lambda:LayerVersion is under "Content". + # the key for S3Key FOR AWS::Lambda::Function is under "Code" + content_key = ( + "Content" + if resource_type == "AWS::Lambda::LayerVersion" + else "Code" + ) + try: + resource_id = resource["Properties"][content_key]["S3Key"].split( + "." + )[0] + except KeyError: + logger.warning( + "found resource without an S3Key (this typically occurs when using inline code or during test)" + ) + continue + + asset = self.assets["files"][resource_id] + asset_source_path = self.path.parent.joinpath(asset["source"]["path"]) + asset_packaging = asset["source"]["packaging"] + + # CDK does not zip assets prior to deployment - we do it here if a zip asset is detected + if asset_packaging == "zip": + # create archive if necessary + logger.info(f"{resource_name} packaging into .zip file") + archive = shutil.make_archive( + base_name=asset_source_path, + format="zip", + root_dir=str(asset_source_path), + ) + elif asset_packaging == "file": + archive = self.cloud_assembly_path.joinpath(asset["source"]["path"]) + else: + raise ValueError( + f"Unsupported asset packaging format: {asset_packaging}" + ) + + # rename archive to match the resource name it was generated for + archive_name = f"{resource_name}.zip" + archive_path = self.cloud_assembly_path.joinpath(archive_name) + shutil.move(src=archive, dst=archive_path) + + # update CloudFormation resource properties for S3Bucket and S3Key + # fmt: off + resource["Properties"][content_key]["S3Bucket"] = { + "Fn::Join": [ # NOSONAR (python:S1192) - string for clarity + "-", + [ + { + "Fn::FindInMap": ["SourceCode", "General", "S3Bucket"] # NOSONAR (python:S1192) - string for clarity + }, + {"Ref": "AWS::Region"}, + ], + ] + } + resource["Properties"][content_key]["S3Key"] = { + "Fn::Join": [ # NOSONAR (python:S1192) - string for clarity + "/", + [ + { + "Fn::FindInMap": ["SourceCode", "General", "KeyPrefix"] # NOSONAR (python:S1192) - string for clarity + }, + archive_name, + ], + ] + } + # fmt: on + + # add resource to the list of regional assets + self.assets_regional.append(archive_path) + + def _build_asset_path(self, asset_path): + asset_output_path = self.cloud_assembly_path.joinpath(asset_path) + asset_output_path.mkdir(parents=True, exist_ok=True) + return asset_output_path + + def save(self, asset_path_global: Path = None, asset_path_regional: Path = None): + """Save the template (will save to the asset paths if specified)""" + self.path.write_text(json.dumps(self.contents, indent=2)) + + # global solutions assets - default folder location is "global-s3-assets" + if asset_path_global: + asset_path = self._build_asset_path(asset_path_global) + for asset in self.assets_global: + shutil.copy( + str(asset), + str(asset_path.joinpath(self.global_asset_name)), + ) + + # regional solutions assets - default folder location is "regional-s3-assets" + if asset_path_regional: + asset_path = self._build_asset_path(asset_path_regional) + for asset in self.assets_regional: + shutil.copy(str(asset), str(asset_path)) + + +@jsii.implements(IStackSynthesizer) +class SolutionStackSubstitions(DefaultStackSynthesizer): + """Used to handle AWS Solutions template substitutions and sanitization""" + + substitutions = None + substitution_re = re.compile("%%[a-zA-Z-_][a-zA-Z-_]+%%") + + def _template_names(self, session: ISynthesisSession) -> List[Path]: + assembly_output_path = Path(session.assembly.outdir) + templates = [assembly_output_path.joinpath(self._stack.template_file)] + + # add this stack's children to the outputs to process (todo: this only works for singly-nested stacks) + for child in self._stack.node.children: + child_template = getattr(child, "template_file", None) + if child_template: + templates.append(assembly_output_path.joinpath(child_template)) + return templates + + def _templates(self, session: ISynthesisSession) -> (Path, Dict): + assembly_output_path = Path(session.assembly.outdir) + + assets = {} + try: + assets = json.loads( + next( + assembly_output_path.glob(self._stack.stack_name + "*.assets.json") + ).read_text() + ) + except StopIteration: + pass # use the default (no assets) + + for path in self._template_names(session): + yield CloudFormationTemplate(path, json.loads(path.read_text()), assets) + + def synthesize(self, session: ISynthesisSession): + # when called with `cdk deploy` this outputs to cdk.out + # when called from python directly, this outputs to a temporary directory + result = DefaultStackSynthesizer.synthesize(self, session) + + asset_path_regional = self._stack.node.try_get_context( + "SOLUTIONS_ASSETS_REGIONAL" + ) + asset_path_global = self._stack.node.try_get_context("SOLUTIONS_ASSETS_GLOBAL") + + logger.info( + f"solutions parameter substitution in {session.assembly.outdir} started" + ) + for template in self._template_names(session): + logger.info(f"substutiting parameters in {str(template)}") + with FileInput(template, inplace=True) as template_lines: + for line in template_lines: + # handle all template subsitutions in the line + for match in SolutionStackSubstitions.substitution_re.findall(line): + placeholder = match.replace("%", "") + replacement = self._stack.node.try_get_context(placeholder) + if not replacement: + raise ValueError( + f"Please provide a parameter substitution for {placeholder} via environment variable or CDK context" + ) + + line = line.replace(match, replacement) + # print the (now substituted) line in the context of template_lines + print(line, end="") + logger.info(f"substituting parameters in {str(template)} completed") + logger.info("solutions parameter substitution completed") + + # do not perform solution resource/ template cleanup if asset paths not passed + if not asset_path_global or not asset_path_regional: + return + + logger.info( + f"solutions template customization in {session.assembly.outdir} started" + ) + for template in self._templates(session): + template.patch_lambda() + template.patch_nested() + template.delete_bootstrap_parameters() + template.delete_cdk_helpers() + template.save( + asset_path_global=asset_path_global, + asset_path_regional=asset_path_regional, + ) + logger.info("solutions template customization completed") + + return result diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/__init__.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/__init__.py new file mode 100644 index 0000000..fa96974 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/__init__.py @@ -0,0 +1,14 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_solutions.cdk.tools.cleaner import Cleaner diff --git a/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/cleaner.py b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/cleaner.py new file mode 100644 index 0000000..7b0e764 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/aws_solutions/cdk/tools/cleaner.py @@ -0,0 +1,77 @@ +# ##################################################################################################################### +# 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 logging +import os +import shutil +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger("cdk-helper") + + +@dataclass +class Cleanable: + """Encapsulates something that can be cleaned by the cleaner""" + + name: str + file_type: str + pattern: str + + def __post_init__(self): + if self.file_type not in ("d", "f"): + raise ValueError("only directories and files are allowed ('d' or 'f')") + + def delete(self, source_dir): + source_path = Path(source_dir) + + for path in source_path.rglob(self.pattern): + if "aws_solutions" not in str( + path.name + ): # prevent the module from being unlinked in a dev environment + if self.file_type == "d" and path.is_dir(): + logger.info(f"deleting {self.name} directory {path}") + shutil.rmtree(path, ignore_errors=True) + if self.file_type == "f" and path.is_file(): + logger.info(f"deleting {self.name} file {path}") + try: + path.unlink() + except FileNotFoundError: + pass + + +class Cleaner: + """Encapsulates functions that help clean up the build environment.""" + + TO_CLEAN = [ + Cleanable("Python bytecode", "f", "*.py[cod]"), + Cleanable("Python Coverage databases", "f", ".coverage"), + Cleanable("CDK Cloud Assemblies", "d", "cdk.out"), + Cleanable("Python egg", "d", "*.egg-info"), + Cleanable("Python bytecode cache", "d", "__pycache__"), + Cleanable("Python test cache", "d", ".pytest_cache"), + ] + + @staticmethod + def clean_dirs(*args): + """Recursively remove each of its arguments, then recreate the directory""" + for dir_to_remove in args: + logger.info("cleaning %s" % dir_to_remove) + shutil.rmtree(dir_to_remove, ignore_errors=True) + os.makedirs(dir_to_remove) + + @staticmethod + def cleanup_source(source_dir): + """Cleans up all items found in TO_CLEAN""" + for item in Cleaner.TO_CLEAN: + item.delete(source_dir) diff --git a/source/cdk_solution_helper_py/helpers_cdk/setup.py b/source/cdk_solution_helper_py/helpers_cdk/setup.py new file mode 100644 index 0000000..98a095b --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_cdk/setup.py @@ -0,0 +1,78 @@ +# ##################################################################################################################### +# 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 re +from pathlib import Path + +import setuptools + +VERSION_RE = re.compile(r"\#\# \[(?P.*)\]", re.MULTILINE) # NOSONAR + + +def get_version(): + """ + Detect the solution version from the changelog. Latest version on top. + """ + changelog = open(Path(__file__).resolve().parent.parent / "CHANGELOG.md").read() + versions = VERSION_RE.findall(changelog) + if not len(versions): + raise ValueError("use the standard semver format in your CHANGELOG.md") + build_version = versions[0] + print(f"Build Version: {build_version}") + return build_version + + +setuptools.setup( + name="aws-solutions-cdk", + version=get_version(), + description="Tools to make AWS Solutions deployments with CDK + Python more manageable", + long_description=open("../README.md").read(), + author="Amazon Web Services", + url="https://aws.amazon.com/solutions/implementations", + license="Apache License 2.0", + packages=setuptools.find_namespace_packages(), + package_data={ + "": [ + "requirements.txt", + "Dockerfile", + "__aws_solutions_bundling_version__", + ] + }, + install_requires=[ + "aws-cdk.core>=1.120.0", + "aws-cdk.aws_lambda>=1.120.0", + "Click>=7.1.2", + "boto3>=1.17.52", + "requests>=2.24.0", + "crhelper>=2.0.6", + ], + entry_points=""" + [console_scripts] + build-s3-cdk-dist=aws_solutions.cdk.scripts.build_s3_cdk_dist:cli + """, + python_requires=">=3.7", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: JavaScript", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Topic :: Software Development :: Code Generators", + "Topic :: Utilities", + "Typing :: Typed", + ], + zip_safe=False, +) diff --git a/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/__init__.py b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/__init__.py new file mode 100644 index 0000000..a608fb6 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/__init__.py @@ -0,0 +1,24 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_solutions.core.config import Config + +config = Config() + +from aws_solutions.core.helpers import ( + get_aws_region, + get_aws_partition, + get_service_client, + get_service_resource, + get_aws_account, +) diff --git a/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/config.py b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/config.py new file mode 100644 index 0000000..ea84a01 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/config.py @@ -0,0 +1,75 @@ +# ##################################################################################################################### +# 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 os +import re +from typing import Dict + +import botocore.config + +from aws_solutions.core.logging import get_logger + +logger = get_logger(__name__) + + +SOLUTION_ID_RE = re.compile(r"^SO(?P\d+)(?P[a-zA-Z]*)$") # NOSONAR +SOLUTION_VERSION_RE = re.compile( + r"^v(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" # NOSONAR +) + + +class SolutionConfigEnv: + def __init__(self, env_var, default: str = "", regex: re.Pattern = None): + self._env_var = env_var + self._regex = regex + self._value = default + + def _get_value_or_default(self) -> str: + if self._value: + return self._value + return os.environ.get(self._env_var) + + def __get__(self, instance, owner) -> str: + value = str(self._get_value_or_default()) + if self._regex and not self._regex.match(value): + raise ValueError( + f"`{value}` received, but environment variable {self._env_var} (or default) must be set and match the pattern {self._regex.pattern}" + ) + return value + + def __set__(self, instance, value) -> None: + self._value = value + + +class Config: + """Stores information about the current solution""" + + id = SolutionConfigEnv("SOLUTION_ID", regex=SOLUTION_ID_RE) + version = SolutionConfigEnv("SOLUTION_VERSION", regex=SOLUTION_VERSION_RE) + _botocore_config = None + + @property + def botocore_config(self) -> botocore.config.Config: + if not self._botocore_config: + self._botocore_config = botocore.config.Config( + **self._botocore_config_defaults + ) + return self._botocore_config + + @botocore_config.setter + def botocore_config(self, other_config: botocore.config.Config): + self._botocore_config = self.botocore_config.merge(other_config) + + @property + def _botocore_config_defaults(self) -> Dict: + return {"user_agent_extra": f"AwsSolution/{self.id}/{self.version}"} diff --git a/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/helpers.py b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/helpers.py new file mode 100644 index 0000000..b4c5ac7 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/helpers.py @@ -0,0 +1,100 @@ +# ##################################################################################################################### +# 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 os + +import boto3 + +import aws_solutions.core.config + +_helpers_service_clients = dict() +_helpers_service_resources = dict() +_session = None + + +class EnvironmentVariableError(Exception): + pass + + +def get_aws_region(): + """ + Get the caller's AWS region from the environment variable AWS_REGION + :return: the AWS region name (e.g. us-east-1) + """ + region = os.environ.get("AWS_REGION") + if not region: + raise EnvironmentVariableError("Missing AWS_REGION environment variable.") + + return region + + +def get_aws_partition(): + """ + Get the caller's AWS partion by driving it from AWS region + :return: partition name for the current AWS region (e.g. aws) + """ + region_name = get_aws_region() + china_region_name_prefix = "cn" + us_gov_cloud_region_name_prefix = "us-gov" + aws_regions_partition = "aws" + aws_china_regions_partition = "aws-cn" + aws_us_gov_cloud_regions_partition = "aws-us-gov" + + # China regions + if region_name.startswith(china_region_name_prefix): + return aws_china_regions_partition + # AWS GovCloud(US) Regions + elif region_name.startswith(us_gov_cloud_region_name_prefix): + return aws_us_gov_cloud_regions_partition + else: + return aws_regions_partition + + +def get_session(): + global _session + if not _session: + _session = boto3.session.Session() + return _session + + +def get_service_client(service_name): + global _helpers_service_clients + config = aws_solutions.core.config.botocore_config + session = get_session() + + if service_name not in _helpers_service_clients: + _helpers_service_clients[service_name] = session.client( + service_name, config=config, region_name=get_aws_region() + ) + return _helpers_service_clients[service_name] + + +def get_service_resource(service_name): + global _helpers_service_resources + config = aws_solutions.core.config.botocore_config + session = get_session() + + if service_name not in _helpers_service_resources: + _helpers_service_resources[service_name] = session.resource( + service_name, config=config, region_name=get_aws_region() + ) + return _helpers_service_resources[service_name] + + +def get_aws_account() -> str: + """ + Get the caller's AWS account ID from STS + :return: the AWS account ID of the caller + """ + sts = get_service_client("sts") + return sts.get_caller_identity().get("Account") diff --git a/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/logging.py b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/logging.py new file mode 100644 index 0000000..269ce90 --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_common/aws_solutions/core/logging.py @@ -0,0 +1,58 @@ +# ##################################################################################################################### +# 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 logging +import os + +DEFAULT_LEVEL = "WARNING" + + +def get_level(): + """ + Get the logging level from the LOG_LEVEL environment variable if it is valid. Otherwise set to WARNING + :return: The logging level to use + """ + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + requested_level = os.environ.get("LOG_LEVEL", DEFAULT_LEVEL) + + if requested_level and requested_level in valid_levels: + return requested_level + + return DEFAULT_LEVEL + + +def get_logger(name): + """ + Get a configured logger. Compatible with both the AWS Lambda runtime (root logger) and local execution + :param name: The name of the logger (most often __name__ of the calling module) + :return: The logger to use + """ + logger = None + + # first case: running as a lambda function or in pytest with conftest + # second case: running a single test or locally under test + if len(logging.getLogger().handlers) > 0: + logger = logging.getLogger() + logger.setLevel(get_level()) + + # overrides + logging.getLogger("boto3").setLevel(logging.WARNING) + logging.getLogger("botocore").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + else: + # fmt: off + logging.basicConfig(level=get_level()) # NOSONAR - log level is user-specified; logs to stdout for AWS Lambda + # fmt: on + logger = logging.getLogger(name) + + return logger diff --git a/source/cdk_solution_helper_py/helpers_common/setup.py b/source/cdk_solution_helper_py/helpers_common/setup.py new file mode 100644 index 0000000..d0a972c --- /dev/null +++ b/source/cdk_solution_helper_py/helpers_common/setup.py @@ -0,0 +1,62 @@ +# ##################################################################################################################### +# 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 re +from pathlib import Path + +import setuptools + +VERSION_RE = re.compile(r"\#\# \[(?P.*)\]", re.MULTILINE) # NOSONAR + + +def get_version(): + """ + Detect the solution version from the changelog. Latest version on top. + """ + changelog = open(Path(__file__).resolve().parent.parent / "CHANGELOG.md").read() + versions = VERSION_RE.findall(changelog) + if not len(versions): + raise ValueError("use the standard semver format in your CHANGELOG.md") + build_version = versions[0] + print(f"Build Version: {build_version}") + return build_version + + +setuptools.setup( + name="aws-solutions-python", + version=get_version(), + description="Tools to make AWS Solutions deployments with CDK + Python more manageable", + long_description=open("../README.md").read(), + author="Amazon Web Services", + url="https://aws.amazon.com/solutions/implementations", + license="Apache License 2.0", + packages=setuptools.find_namespace_packages(), + install_requires=[ + "boto3>=1.17.52", + ], + python_requires=">=3.7", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: JavaScript", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Topic :: Software Development :: Code Generators", + "Topic :: Utilities", + "Typing :: Typed", + ], + zip_safe=False, +) diff --git a/source/cdk_solution_helper_py/requirements-dev.txt b/source/cdk_solution_helper_py/requirements-dev.txt new file mode 100644 index 0000000..ac24f7b --- /dev/null +++ b/source/cdk_solution_helper_py/requirements-dev.txt @@ -0,0 +1,17 @@ +aws-cdk.core>=1.120.0 +aws-cdk.aws_lambda>=1.120.0 +black +boto3>=1.17.49 +requests>=2.24.0 +crhelper>=2.0.6 +Click +moto +pipenv +poetry +pytest +pytest-cov>=2.11.1 +pytest-mock>=3.5.1 +tox +tox-pyenv +-e helpers_cdk +-e helpers_common \ No newline at end of file diff --git a/source/images/solution-architecture.jpg b/source/images/solution-architecture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..519f953b2578577e43cb3fb0925bda9ccde6dfe3 GIT binary patch literal 303310 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

  • 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⁢u;ZmU7ntK$7%ZEwl9!YdE9;zDL-7p^JS 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:stack/stack-name) + + This requires the use of the CloudFormation CAPABILITY_NAMED_IAM permission but means + the name of the role will be maintained across the same named deployment of the stack, + but be different regionally (and by partition). Without this set, removing and re-adding KMS + permission to the stack would result in a different role name being created, which would make + resources in Amazon Personalize inaccessible to this solution. + + Note that Personalize must also be granted access to the KMS key via key policy to use the + parameter. Consult the implementation guide for an example key policy. + :return: None + """ + hashed_name = ResourceHash( + self, + "KmsResourceNameHash", + purpose="PersonalizeKMSReadWriteRole", + max_length=64, + ) + + kms_role = iam.Role( + self, + "PersonalizeKMSReadWriteRole", + role_name=hashed_name.resource_name.to_string(), + description="Grants Amazon Personalize access to use the specified KMS Key for SSE-KMS", + assumed_by=iam.ServicePrincipal("personalize.amazonaws.com"), + inline_policies={ + "PersonalizeKmsWriteAccess": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "kms:Encrypt", + "kms:ReEncrypt", + "kms:Decrypt", + "kms:CreateGrant", + "kms:RevokeGrant", + "kms:RetireGrant", + "kms:ListGrants", + "kms:GenerateDataKey", + "kms:DescribeKey", + ], + resources=[self.kms_key.value_as_string], + ) + ] + ), + "PersonalizeKmsReadAccess": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "kms:Decrypt", + "kms:DescribeKey", + "kms:GenerateDataKey", + ], + resources=[self.kms_key.value_as_string], + ) + ], + ), + }, + ) + kms_role.node.default_child.cfn_options.condition = self.kms_enabled + add_cfn_nag_suppressions( + kms_role.node.default_child, + [ + CfnNagSuppression( + "W28", + "Resource requires consistent name across stack deployments and is unique per region + stack", + ), + ], + ) + + # Grant the function access to Amazon Personalize + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "personalize:DescribeDatasetGroup", + "personalize:CreateDatasetGroup", + ], + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:dataset-group/*" + ], + ) + ) + + # Grant the function access to pass the role + kms_role_passrole = iam.ManagedPolicy( + self, + "PersonalizeKmsPassRole", + document=iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["iam:PassRole"], + resources=[kms_role.role_arn], + ) + ] + ), + roles=[self.function.role], + ) + kms_role_passrole.node.default_child.cfn_options.condition = self.kms_enabled + + self.function.add_environment( + "KMS_ROLE_ARN", + Fn.condition_if( + self.kms_enabled.node.id, kms_role.role_arn, "" + ).to_string(), + ) + self.function.add_environment( + "KMS_KEY_ARN", + Fn.condition_if( + self.kms_enabled.node.id, self.kms_key.value_as_string, "" + ).to_string(), + ) diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_dataset_import_job.py b/source/infrastructure/personalize/aws_lambda/functions/create_dataset_import_job.py new file mode 100644 index 0000000..3f2d636 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_dataset_import_job.py @@ -0,0 +1,110 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_s3 import IBucket +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import Construct, Aws + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateDatasetImportJob(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + personalize_bucket: IBucket, + layers=None, + failure_state: Optional[IChainable] = None, + ): + self.personalize_bucket = personalize_bucket + self.personalize_role = iam.Role( + scope, + "PersonalizeS3ReadRole", + description="Grants Amazon Personalize access to read from S3", + assumed_by=iam.ServicePrincipal("personalize.amazonaws.com"), + inline_policies={ + "PersonalizeS3ReadPolicy": iam.PolicyDocument( + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "s3:GetObject", + "s3:ListBucket", + ], + resources=[ + personalize_bucket.arn_for_objects("*"), + personalize_bucket.bucket_arn, + ], + ) + ] + ) + }, + ) + personalize_bucket.add_to_resource_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "s3:GetObject", + "s3:ListBucket", + ], + resources=[ + personalize_bucket.arn_for_objects("*"), + personalize_bucket.bucket_arn, + ], + principals=[iam.ServicePrincipal("personalize.amazonaws.com")], + ) + ) + + super().__init__( + scope, + id, + layers=layers, + failure_state=failure_state, + ) + + def _set_permissions(self): + # personalize resource permissions + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "personalize:DescribeDatasetGroup", + "personalize:DescribeSchema", + "personalize:DescribeDataset", + "personalize:CreateDatasetImportJob", + "personalize:DescribeDatasetImportJob", + "personalize:ListDatasetImportJobs", + ], + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:dataset-group/*", + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:schema/*", + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:dataset/*", + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:dataset-import-job/*", + ], + ) + ) + self.personalize_bucket.grant_read(self.function, "train/*") + + # passrole permissions + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["iam:PassRole"], + resources=[self.personalize_role.role_arn], + ) + ) + self.function.add_environment("ROLE_ARN", self.personalize_role.role_arn) diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_event_tracker.py b/source/infrastructure/personalize/aws_lambda/functions/create_event_tracker.py new file mode 100644 index 0000000..584c147 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_event_tracker.py @@ -0,0 +1,59 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_stepfunctions import IChainable, TaskInput +from aws_cdk.core import Construct, Aws + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateEventTracker(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + failure_state: Optional[IChainable] = None, + ): + super().__init__( + scope, + id, + payload=TaskInput.from_object( + { + "name.$": "$.eventTracker.serviceConfig.name", + "datasetGroupArn.$": "$.datasetGroup.serviceConfig.datasetGroupArn", + } + ), + result_path="$.eventTracker.serviceConfig", + layers=layers, + failure_state=failure_state, + ) + + def _set_permissions(self): + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "personalize:DescribeEventTracker", + "personalize:ListEventTrackers", + "personalize:CreateEventTracker", + ], + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:dataset-group/*", + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:event-tracker/*", + ], + ) + ) diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_filter.py b/source/infrastructure/personalize/aws_lambda/functions/create_filter.py new file mode 100644 index 0000000..c6d2a9e --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_filter.py @@ -0,0 +1,52 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import Construct, Aws + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateFilter(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + failure_state: Optional[IChainable] = None, + ): + super().__init__( + scope, + id, + layers=layers, + failure_state=failure_state, + ) + + def _set_permissions(self): + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "personalize:DescribeDatasetGroup", + "personalize:CreateFilter", + "personalize:DescribeFilter", + ], + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:filter/*", + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:dataset-group/*", + ], + ) + ) diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_scheduled_task.py b/source/infrastructure/personalize/aws_lambda/functions/create_scheduled_task.py new file mode 100644 index 0000000..97cb942 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_scheduled_task.py @@ -0,0 +1,33 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from aws_cdk.core import Construct + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateScheduledTask(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + ): + super().__init__( + scope, + id, + layers=layers, + ) + + def _set_permissions(self): + pass # NOSONAR (python:S1186) - no permissions required diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_schema.py b/source/infrastructure/personalize/aws_lambda/functions/create_schema.py new file mode 100644 index 0000000..79b9182 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_schema.py @@ -0,0 +1,50 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import Construct, Aws + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateSchema(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + failure_state: Optional[IChainable] = None, + ): + super().__init__( + scope, + id, + layers=layers, + failure_state=failure_state, + ) + + def _set_permissions(self): + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "personalize:DescribeSchema", + "personalize:CreateSchema", + ], + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:schema/*" + ], + ) + ) diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_solution.py b/source/infrastructure/personalize/aws_lambda/functions/create_solution.py new file mode 100644 index 0000000..ef1e597 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_solution.py @@ -0,0 +1,48 @@ +# ###################################################################################################################### +# 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 aws_cdk.aws_iam as iam +from aws_cdk.core import Construct, Aws + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateSolution(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + ): + super().__init__( + scope, + id, + layers=layers, + ) + + def _set_permissions(self): + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "personalize:DescribeSolution", + "personalize:CreateSolution", + "personalize:ListSolutions", + "personalize:DescribeDatasetGroup", + ], + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:solution/*", + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:dataset-group/*", + ], + ) + ) diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_solution_version.py b/source/infrastructure/personalize/aws_lambda/functions/create_solution_version.py new file mode 100644 index 0000000..d442782 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_solution_version.py @@ -0,0 +1,49 @@ +# ###################################################################################################################### +# 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 aws_cdk.aws_iam as iam +from aws_cdk.core import Construct, Aws + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateSolutionVersion(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + ): + super().__init__( + scope, + id, + layers=layers, + ) + + def _set_permissions(self): + self.function.add_to_role_policy( + statement=iam.PolicyStatement( + actions=[ + "personalize:DescribeSolutionVersion", + "personalize:CreateSolutionVersion", + "personalize:ListSolutionVersions", + "personalize:DescribeSolution", + "personalize:GetSolutionMetrics", + ], + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:solution-version/*", + f"arn:{Aws.PARTITION}:personalize:{Aws.REGION}:{Aws.ACCOUNT_ID}:solution/*", + ], + ) + ) diff --git a/source/infrastructure/personalize/aws_lambda/functions/create_timestamp.py b/source/infrastructure/personalize/aws_lambda/functions/create_timestamp.py new file mode 100644 index 0000000..5811b43 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/create_timestamp.py @@ -0,0 +1,29 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from aws_cdk.core import Construct + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateTimestamp(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + ): + super().__init__(scope, id, layers=layers) + + def _set_permissions(self): + pass # NOSONAR (python:S1186) - no permissions required diff --git a/source/infrastructure/personalize/aws_lambda/functions/environment.py b/source/infrastructure/personalize/aws_lambda/functions/environment.py new file mode 100644 index 0000000..f18c809 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/environment.py @@ -0,0 +1,48 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from dataclasses import dataclass, field + +from aws_cdk.aws_lambda import IFunction +from aws_cdk.core import Aws + +from personalize.aws_lambda.functions.environment_variable import EnvironmentVariable + + +@dataclass +class Environment: + """ + Tracks environment variables common to AWS Lambda functions deployed by this solution + """ + + scope: IFunction + solution_name: EnvironmentVariable = field(init=False, repr=False) + solution_id: EnvironmentVariable = field(init=False, repr=False) + solution_version: EnvironmentVariable = field(init=False, repr=False) + log_level: EnvironmentVariable = field(init=False, repr=False) + powertools_service_name: EnvironmentVariable = field(init=False, repr=False) + + def __post_init__(self): + cloudwatch_namespace_id = f"personalize_solution_{Aws.STACK_NAME}" + cloudwatch_service_id_default = f"Workflow" + + self.solution_name = EnvironmentVariable(self.scope, "SOLUTION_NAME") + self.solution_id = EnvironmentVariable(self.scope, "SOLUTION_ID") + self.solution_version = EnvironmentVariable(self.scope, "SOLUTION_VERSION") + self.log_level = EnvironmentVariable(self.scope, "LOG_LEVEL", "INFO") + self.powertools_service_name = EnvironmentVariable( + self.scope, "POWERTOOLS_SERVICE_NAME", cloudwatch_service_id_default + ) + self.powertools_metrics_namespace = EnvironmentVariable( + self.scope, "POWERTOOLS_METRICS_NAMESPACE", cloudwatch_namespace_id + ) diff --git a/source/infrastructure/personalize/aws_lambda/functions/environment_variable.py b/source/infrastructure/personalize/aws_lambda/functions/environment_variable.py new file mode 100644 index 0000000..9a360d5 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/environment_variable.py @@ -0,0 +1,31 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from dataclasses import dataclass, field + +from aws_cdk.aws_lambda import IFunction + + +@dataclass +class EnvironmentVariable: + scope: IFunction + name: str + value: str = field(default="") + + def __post_init__(self): + if not self.value: + self.value = self.scope.node.try_get_context(self.name) + self.scope.add_environment(self.name, self.value) + + def __str__(self): + return self.value diff --git a/source/infrastructure/personalize/aws_lambda/functions/s3_event.py b/source/infrastructure/personalize/aws_lambda/functions/s3_event.py new file mode 100644 index 0000000..44fc8e3 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/s3_event.py @@ -0,0 +1,72 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path + +from aws_cdk.aws_lambda import Tracing, Runtime, RuntimeFamily +from aws_cdk.aws_s3 import Bucket +from aws_cdk.aws_sns import Topic +from aws_cdk.aws_stepfunctions import StateMachine +from aws_cdk.core import Construct, Duration + +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonFunction +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression +from personalize.aws_lambda.functions.environment import Environment + + +class S3EventHandler(SolutionsPythonFunction): + def __init__( + self, + scope: Construct, + construct_id: str, + state_machine: StateMachine, + bucket: Bucket, + topic: Topic, + **kwargs + ): + entrypoint = ( + Path(__file__).absolute().parents[4] + / "aws_lambda" + / "s3_event" + / "handler.py" + ) + function = "lambda_handler" + kwargs["libraries"] = [ + Path(__file__).absolute().parents[4] / "aws_lambda" / "shared" + ] + kwargs["tracing"] = Tracing.ACTIVE + kwargs["timeout"] = Duration.seconds(15) + kwargs["runtime"] = Runtime("python3.9", RuntimeFamily.PYTHON) + + super().__init__(scope, construct_id, entrypoint, function, **kwargs) + + self.environment = Environment(self) + self.add_environment("STATE_MACHINE_ARN", state_machine.state_machine_arn) + + add_cfn_nag_suppressions( + self.role.node.try_find_child("DefaultPolicy").node.find_child("Resource"), + [ + CfnNagSuppression( + "W12", "IAM policy for AWS X-Ray requires an allow on *" + ) + ], + ) + + bucket.grant_read(self, objects_key_pattern="train/*") + state_machine.grant_start_execution(self) + + self.grant_publish(topic) + + def grant_publish(self, topic: Topic): + topic.grant_publish(self) + self.add_environment("SNS_TOPIC_ARN", topic.topic_arn) diff --git a/source/infrastructure/personalize/aws_lambda/functions/sns_notification.py b/source/infrastructure/personalize/aws_lambda/functions/sns_notification.py new file mode 100644 index 0000000..571e141 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/sns_notification.py @@ -0,0 +1,53 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path + +from aws_cdk.aws_lambda import Tracing, Runtime, RuntimeFamily +from aws_cdk.aws_sns import Topic +from aws_cdk.core import Construct, Duration + +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonFunction +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression +from personalize.aws_lambda.functions.environment import Environment + + +class SNSNotification(SolutionsPythonFunction): + def __init__(self, scope: Construct, construct_id: str, **kwargs): + entrypoint = ( + Path(__file__).absolute().parents[4] + / "aws_lambda" + / "sns_notification" + / "handler.py" + ) + function = "lambda_handler" + kwargs["tracing"] = Tracing.ACTIVE + kwargs["timeout"] = Duration.seconds(15) + kwargs["runtime"] = Runtime("python3.9", RuntimeFamily.PYTHON) + + super().__init__(scope, construct_id, entrypoint, function, **kwargs) + + self.environment = Environment(self) + + add_cfn_nag_suppressions( + self.role.node.try_find_child("DefaultPolicy").node.find_child("Resource"), + [ + CfnNagSuppression( + "W12", "IAM policy for AWS X-Ray requires an allow on *" + ) + ], + ) + + def grant_publish(self, topic: Topic): + topic.grant_publish(self) + self.add_environment("SNS_TOPIC_ARN", topic.topic_arn) diff --git a/source/infrastructure/personalize/aws_lambda/functions/solutionstep.py b/source/infrastructure/personalize/aws_lambda/functions/solutionstep.py new file mode 100644 index 0000000..7805672 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/functions/solutionstep.py @@ -0,0 +1,138 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path +from typing import Optional + +from aws_cdk.aws_lambda import Tracing, Runtime, RuntimeFamily +from aws_cdk.aws_stepfunctions import IChainable, TaskInput, State +from aws_cdk.core import Construct, Duration + +from aws_solutions.cdk.aws_lambda.python.function import SolutionsPythonFunction +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression +from personalize.aws_lambda.functions.environment import Environment +from personalize.step_functions.personalization_fragment import PersonalizationFragment + + +class SolutionStep(Construct): + def __init__( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + id: str, + function: str = "lambda_handler", + entrypoint: Path = None, + input_path: str = "$", + result_path: str = "$", + output_path: str = "$", + payload: Optional[TaskInput] = None, + layers=None, + failure_state: Optional[IChainable] = None, + ): + super().__init__(scope, f"{id} Solution Step") + + self.function = self._CreateLambdaFunction( + self, + f"{self._snake_case(id)}_fn", + layers=layers, + function=function, + entrypoint=entrypoint, + ) + add_cfn_nag_suppressions( + self.function.role.node.try_find_child("DefaultPolicy").node.find_child( + "Resource" + ), + [ + CfnNagSuppression( + "W12", "IAM policy for AWS X-Ray requires an allow on *" + ) + ], + ) + + self._input_path = input_path + self._result_path = result_path + self._output_path = output_path + self._payload = payload + self._failure_state = failure_state + + self._create_resources() + self._set_permissions() + self.environment = self._set_environment() + + def state( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + construct_id, + payload: Optional[TaskInput] = None, + input_path: Optional[str] = None, + result_path: Optional[str] = None, + result_selector: Optional[str] = None, + output_path: Optional[str] = None, + failure_state: Optional[State] = None, + **kwargs, + ): + payload = payload or self._payload + input_path = input_path or self._input_path + result_path = result_path or self._result_path + output_path = output_path or self._output_path + failure_state = failure_state or self._failure_state + + return PersonalizationFragment( + scope, + construct_id, + function=self.function, + payload=payload, + input_path=input_path, + result_path=result_path, + output_path=output_path, + failure_state=failure_state, + result_selector=result_selector, + **kwargs, + ) + + def _snake_case(self, name) -> str: + return name.replace(" ", "_").lower() + + def _set_permissions(self) -> None: + raise NotImplementedError("please implement _set_permissions") + + def _create_resources(self) -> None: + pass # not required + + def _set_environment(self) -> Environment: + return Environment(self.function) + + class _CreateLambdaFunction(SolutionsPythonFunction): + def __init__(self, scope: Construct, construct_id: str, **kwargs): + entrypoint = kwargs.pop("entrypoint", None) + if not entrypoint: + entrypoint = ( + Path(__file__).absolute().parents[4] + / "aws_lambda" + / construct_id.replace("_fn", "") + / "handler.py" + ) + libraries = [Path(__file__).absolute().parents[4] / "aws_lambda" / "shared"] + function = kwargs.pop("function") + kwargs["layers"] = kwargs.get("layers", []) + kwargs["tracing"] = Tracing.ACTIVE + kwargs["timeout"] = Duration.seconds(15) + kwargs["runtime"] = Runtime("python3.9", RuntimeFamily.PYTHON) + + super().__init__( + scope, + construct_id, + entrypoint, + function, + libraries=libraries, + **kwargs, + ) diff --git a/source/infrastructure/personalize/aws_lambda/layers/__init__.py b/source/infrastructure/personalize/aws_lambda/layers/__init__.py new file mode 100644 index 0000000..e1f85ba --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/layers/__init__.py @@ -0,0 +1,15 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from personalize.aws_lambda.layers.aws_lambda_powertools.layer import PowertoolsLayer +from personalize.aws_lambda.layers.aws_solutions.layer import SolutionsLayer diff --git a/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/__init__.py b/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/layer.py b/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/layer.py new file mode 100644 index 0000000..c46f933 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/layer.py @@ -0,0 +1,33 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path + +from aws_cdk.core import Construct, Stack + +from aws_solutions.cdk.aws_lambda.python.layer import SolutionsPythonLayerVersion + + +class PowertoolsLayer(SolutionsPythonLayerVersion): + def __init__(self, scope: Construct, construct_id: str, **kwargs): + requirements_path: Path = Path(__file__).absolute().parent / "requirements" + super().__init__(scope, construct_id, requirements_path, **kwargs) + + @staticmethod + def get_or_create(scope: Construct, **kwargs): + stack = Stack.of(scope) + construct_id = "PowertoolsLayer-8E932F0F-197D-4026-A354-23D184C2A624" + exists = stack.node.try_find_child(construct_id) + if exists: + return exists + return PowertoolsLayer(stack, construct_id, **kwargs) diff --git a/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/requirements/requirements.txt b/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/requirements/requirements.txt new file mode 100644 index 0000000..b28f931 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/layers/aws_lambda_powertools/requirements/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools==1.15.0 \ No newline at end of file diff --git a/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/__init__.py b/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/layer.py b/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/layer.py new file mode 100644 index 0000000..597c91c --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/layer.py @@ -0,0 +1,33 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path + +from aws_cdk.core import Construct, Stack + +from aws_solutions.cdk.aws_lambda.python.layer import SolutionsPythonLayerVersion + + +class SolutionsLayer(SolutionsPythonLayerVersion): + def __init__(self, scope: Construct, construct_id: str, **kwargs): + requirements_path: Path = Path(__file__).absolute().parent / "requirements" + super().__init__(scope, construct_id, requirements_path, **kwargs) + + @staticmethod + def get_or_create(scope: Construct, **kwargs): + stack = Stack.of(scope) + construct_id = "SolutionsLayer-DAE8E12F-3DEA-43FB-A4AA-E55AC50BD2E9" + exists = stack.node.try_find_child(construct_id) + if exists: + return exists + return SolutionsLayer(stack, construct_id, **kwargs) diff --git a/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/requirements/requirements.txt b/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/requirements/requirements.txt new file mode 100644 index 0000000..5115579 --- /dev/null +++ b/source/infrastructure/personalize/aws_lambda/layers/aws_solutions/requirements/requirements.txt @@ -0,0 +1,5 @@ +../../../../../../cdk_solution_helper_py/helpers_common +avro==1.10.2 +cronex==0.1.3.1 +jmespath==0.10.0 +parsedatetime==2.6 \ No newline at end of file diff --git a/source/infrastructure/personalize/cloudwatch/__init__.py b/source/infrastructure/personalize/cloudwatch/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/infrastructure/personalize/cloudwatch/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/infrastructure/personalize/cloudwatch/dashboard.py b/source/infrastructure/personalize/cloudwatch/dashboard.py new file mode 100644 index 0000000..51e1159 --- /dev/null +++ b/source/infrastructure/personalize/cloudwatch/dashboard.py @@ -0,0 +1,146 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import Optional + +import aws_cdk.aws_cloudwatch as cw +from aws_cdk.core import Construct, Aws + +GREEN = "#32cd32" +RED = "#ff4500" +BLUE = "#4682b4" + +QUICK_LINKS = """ +# Quick Links + +| Link | Description | +|-|-| +|[button:primary:Personalize](https://console.aws.amazon.com/personalize/home?region={region}#datasetGroups)|Check the status of your managed resources in Amazon Personalize| +|[button:primary:S3](https://s3.console.aws.amazon.com/s3/buckets/{personalize_bucket_name}?region={region}&tab=objects)|Upload your workflow configuration and personalization data to S3 to trigger workflows| +|[button:primary:Scheduler](https://console.aws.amazon.com/states/home?region={region}#/statemachines/view/{scheduler_sfn_arn}?statusFilter=RUNNING)|Check out the running scheduler jobs for your personalization workflow | +""" + + +class Dashboard(Construct): + def __init__( + self, + scope: Construct, + id: str, + scheduler_sfn_arn: str, + personalize_bucket_name: str, + ): + super().__init__(scope, id) + + self.dashboard = cw.Dashboard( + self, + "PersonalizeDashboard", + dashboard_name=f"PersonalizeSolution-{Aws.STACK_NAME}", + period_override=cw.PeriodOverride.AUTO, + start="-PT1D", + ) + self.dashboard.add_widgets( + cw.Row( + cw.Column( + cw.SingleValueWidget( + title="Personalization Configurations Processed", + metrics=[ + self._metric("ConfigurationsProcessed", "Processed", BLUE), + self._metric( + "ConfigurationsProcessedSuccesses", "Succeeded", GREEN + ), + self._metric( + "ConfigurationsProcessedFailures", "Failures", RED + ), + ], + set_period_to_time_range=True, + width=12, + height=3, + ), + cw.SingleValueWidget( + title="Personalization Workflow Status", + metrics=[ + self._metric( + "JobSuccess", "Workflow Jobs Succeeded", GREEN + ), + self._metric("JobFailure", "Workflow Jobs Failed", RED), + self._metric( + "JobsCreated", + "Scheduler Jobs Created", + GREEN, + service="Scheduler", + ), + self._metric( + "JobsDeleted", + "Scheduler Jobs Deleted", + RED, + service="Scheduler", + ), + ], + set_period_to_time_range=True, + width=12, + height=3, + ), + cw.SingleValueWidget( + title="Amazon Personalize Resources Created", + metrics=[ + self._metric( + "DatasetGroupCreated", "Dataset Groups Created" + ), + self._metric("DatasetCreated", "Datasets Created"), + self._metric( + "EventTrackerCreated", "Event Trackers Created" + ), + self._metric("SolutionCreated", "Solutions Created"), + self._metric( + "SolutionVersionCreated", "Solution Versions Created" + ), + self._metric("CampaignCreated", "Campaigns Created"), + self._metric( + "BatchInferenceJobCreated", + "Batch Inference Jobs Created", + ), + self._metric("FilterCreated", "Filters Created"), + ], + set_period_to_time_range=True, + width=12, + height=9, + ), + ), + cw.Column( + cw.TextWidget( + markdown=QUICK_LINKS.format( + region=Aws.REGION, + personalize_bucket_name=personalize_bucket_name, + scheduler_sfn_arn=scheduler_sfn_arn, + ), + height=6, + width=6, + ) + ), + ) + ) + + def _metric( + self, name: str, label: str, color: Optional[str] = None, service="Workflow" + ) -> cw.Metric: + return cw.Metric( + namespace=f"personalize_solution_{Aws.STACK_NAME}", + metric_name=name, + dimensions={"service": service}, + label=label, + statistic="Sum", + color=color, + ) + + @property + def name(self) -> str: + return self.dashboard.node.default_child.ref diff --git a/source/infrastructure/personalize/s3/__init__.py b/source/infrastructure/personalize/s3/__init__.py new file mode 100644 index 0000000..2b3ca6e --- /dev/null +++ b/source/infrastructure/personalize/s3/__init__.py @@ -0,0 +1,15 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from personalize.s3.access_logs_bucket import AccessLogsBucket +from personalize.s3.data_bucket import DataBucket diff --git a/source/infrastructure/personalize/s3/access_logs_bucket.py b/source/infrastructure/personalize/s3/access_logs_bucket.py new file mode 100644 index 0000000..2533325 --- /dev/null +++ b/source/infrastructure/personalize/s3/access_logs_bucket.py @@ -0,0 +1,18 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from personalize.s3.utils import SecureBucket + + +class AccessLogsBucket(SecureBucket): + pass diff --git a/source/infrastructure/personalize/s3/data_bucket.py b/source/infrastructure/personalize/s3/data_bucket.py new file mode 100644 index 0000000..c38f799 --- /dev/null +++ b/source/infrastructure/personalize/s3/data_bucket.py @@ -0,0 +1,18 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from personalize.s3.utils import SecureBucket + + +class DataBucket(SecureBucket): + pass diff --git a/source/infrastructure/personalize/s3/utils.py b/source/infrastructure/personalize/s3/utils.py new file mode 100644 index 0000000..49152c7 --- /dev/null +++ b/source/infrastructure/personalize/s3/utils.py @@ -0,0 +1,71 @@ +# ###################################################################################################################### +# 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 logging +from typing import List + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_s3 import Bucket, BucketEncryption, BlockPublicAccess +from aws_cdk.core import RemovalPolicy, Construct, CfnResource + +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression + +logger = logging.getLogger("cdk-helper") + + +class SecureBucket(Bucket): + def __init__( + self, + scope: Construct, + construct_id: str, + suppress: List[CfnNagSuppression] = None, + **kwargs, + ): + self.construct_id = construct_id + + kwargs = self.override_configuration( + kwargs, "removal_policy", RemovalPolicy.RETAIN + ) + kwargs = self.override_configuration( + kwargs, "encryption", BucketEncryption.S3_MANAGED + ) + kwargs = self.override_configuration( + kwargs, "block_public_access", BlockPublicAccess.BLOCK_ALL + ) + + super().__init__(scope, construct_id, **kwargs) + + self.add_to_resource_policy( + iam.PolicyStatement( + sid="HttpsOnly", + resources=[ + self.arn_for_objects("*"), + ], + actions=["*"], + effect=iam.Effect.DENY, + principals=[iam.AnyPrincipal()], + conditions={"Bool": {"aws:SecureTransport": False}}, + ) + ) + + bucket_cfn: CfnResource = self.node.default_child + bucket_cfn.override_logical_id(construct_id) + if suppress: + add_cfn_nag_suppressions(bucket_cfn, suppress) + + def override_configuration(self, config, key, default=None): + if not config.get(key): + config[key] = default + else: + logger.warning(f"overriding {key} may reduce the security of the solution") + return config diff --git a/source/infrastructure/personalize/scheduler/__init__.py b/source/infrastructure/personalize/scheduler/__init__.py new file mode 100644 index 0000000..cc56cd7 --- /dev/null +++ b/source/infrastructure/personalize/scheduler/__init__.py @@ -0,0 +1,14 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from personalize.scheduler.base import Scheduler diff --git a/source/infrastructure/personalize/scheduler/aws_lambda/__init__.py b/source/infrastructure/personalize/scheduler/aws_lambda/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/infrastructure/personalize/scheduler/aws_lambda/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/infrastructure/personalize/scheduler/aws_lambda/functions/__init__.py b/source/infrastructure/personalize/scheduler/aws_lambda/functions/__init__.py new file mode 100644 index 0000000..f6370b0 --- /dev/null +++ b/source/infrastructure/personalize/scheduler/aws_lambda/functions/__init__.py @@ -0,0 +1,25 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from personalize.scheduler.aws_lambda.functions.create_scheduled_task import ( + CreateScheduledTask, +) +from personalize.scheduler.aws_lambda.functions.delete_scheduled_task import ( + DeleteScheduledTask, +) +from personalize.scheduler.aws_lambda.functions.read_scheduled_task import ( + ReadScheduledTask, +) +from personalize.scheduler.aws_lambda.functions.update_scheduled_task import ( + UpdateScheduledTask, +) diff --git a/source/infrastructure/personalize/scheduler/aws_lambda/functions/create_scheduled_task.py b/source/infrastructure/personalize/scheduler/aws_lambda/functions/create_scheduled_task.py new file mode 100644 index 0000000..1fd4a2d --- /dev/null +++ b/source/infrastructure/personalize/scheduler/aws_lambda/functions/create_scheduled_task.py @@ -0,0 +1,75 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import Construct + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class CreateScheduledTask(SolutionStep): + def __init__( + self, # NOSONAR (python: S107) - allow large number of method parameters + scope: Construct, + id: str, + layers=None, + failure_state: Optional[IChainable] = None, + scheduler_table: ITable = None, + state_machine_arn: str = None, + state_machine_executions_arn: str = None, + ): + self.scheduler_table = scheduler_table + self.state_machine_arn = state_machine_arn + self.state_machine_executions_arn = state_machine_executions_arn + + super().__init__( + scope, + id, + layers=layers, + failure_state=failure_state, + function="create_schedule", + entrypoint=Path(__file__).parents[5].resolve() + / "aws_lambda" + / "scheduler" + / "handler.py", + ) + + def _set_permissions(self): + self.function.add_environment( + "DDB_SCHEDULER_STEPFUNCTION", self.state_machine_arn + ) + self.function.add_to_role_policy( + iam.PolicyStatement( + actions=[ + "states:StartExecution", + "states:ListExecutions", + "states:StopExecution", + "states:DescribeExecution", + ], + effect=iam.Effect.ALLOW, + resources=[ + self.state_machine_arn, + self.state_machine_executions_arn, + ], + ) + ) + + self.scheduler_table.grant_read_write_data(self.function) + self.function.add_environment( + "DDB_SCHEDULES_TABLE", self.scheduler_table.table_name + ) diff --git a/source/infrastructure/personalize/scheduler/aws_lambda/functions/delete_scheduled_task.py b/source/infrastructure/personalize/scheduler/aws_lambda/functions/delete_scheduled_task.py new file mode 100644 index 0000000..67cd8ca --- /dev/null +++ b/source/infrastructure/personalize/scheduler/aws_lambda/functions/delete_scheduled_task.py @@ -0,0 +1,65 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import Construct + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class DeleteScheduledTask(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + failure_state: Optional[IChainable] = None, + scheduler_table: ITable = None, + state_machine_arn: str = None, + ): + self.scheduler_table = scheduler_table + self.state_machine_arn = state_machine_arn + + super().__init__( + scope, + id, + layers=layers, + failure_state=failure_state, + function="delete_schedule", + entrypoint=Path(__file__).parents[5].resolve() + / "aws_lambda" + / "scheduler" + / "handler.py", + ) + + def _set_permissions(self): + self.function.add_environment( + "DDB_SCHEDULER_STEPFUNCTION", self.state_machine_arn + ) + self.function.add_to_role_policy( + iam.PolicyStatement( + actions=["states:StartExecution"], + effect=iam.Effect.ALLOW, + resources=[self.state_machine_arn], + ) + ) + + self.scheduler_table.grant_read_write_data(self.function) + self.function.add_environment( + "DDB_SCHEDULES_TABLE", self.scheduler_table.table_name + ) diff --git a/source/infrastructure/personalize/scheduler/aws_lambda/functions/read_scheduled_task.py b/source/infrastructure/personalize/scheduler/aws_lambda/functions/read_scheduled_task.py new file mode 100644 index 0000000..22a3420 --- /dev/null +++ b/source/infrastructure/personalize/scheduler/aws_lambda/functions/read_scheduled_task.py @@ -0,0 +1,65 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import Construct + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class ReadScheduledTask(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + layers=None, + failure_state: Optional[IChainable] = None, + scheduler_table: ITable = None, + state_machine_arn: str = None, + ): + self.scheduler_table = scheduler_table + self.state_machine_arn = state_machine_arn + + super().__init__( + scope, + id, + layers=layers, + failure_state=failure_state, + function="read_schedule", + entrypoint=Path(__file__).parents[5].resolve() + / "aws_lambda" + / "scheduler" + / "handler.py", + ) + + def _set_permissions(self): + self.function.add_environment( + "DDB_SCHEDULER_STEPFUNCTION", self.state_machine_arn + ) + self.function.add_to_role_policy( + iam.PolicyStatement( + actions=["states:StartExecution"], + effect=iam.Effect.ALLOW, + resources=[self.state_machine_arn], + ) + ) + + self.scheduler_table.grant_read_data(self.function) + self.function.add_environment( + "DDB_SCHEDULES_TABLE", self.scheduler_table.table_name + ) diff --git a/source/infrastructure/personalize/scheduler/aws_lambda/functions/update_scheduled_task.py b/source/infrastructure/personalize/scheduler/aws_lambda/functions/update_scheduled_task.py new file mode 100644 index 0000000..2876909 --- /dev/null +++ b/source/infrastructure/personalize/scheduler/aws_lambda/functions/update_scheduled_task.py @@ -0,0 +1,75 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from pathlib import Path +from typing import Optional + +import aws_cdk.aws_iam as iam +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import Construct + +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class UpdateScheduledTask(SolutionStep): + def __init__( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + id: str, + layers=None, + failure_state: Optional[IChainable] = None, + scheduler_table: ITable = None, + state_machine_arn: str = None, + state_machine_executions_arn: str = None, + ): + self.scheduler_table = scheduler_table + self.state_machine_arn = state_machine_arn + self.state_machine_executions_arn = state_machine_executions_arn + + super().__init__( + scope, + id, + layers=layers, + failure_state=failure_state, + function="update_schedule", + entrypoint=Path(__file__).parents[5].resolve() + / "aws_lambda" + / "scheduler" + / "handler.py", + ) + + def _set_permissions(self): + self.function.add_environment( + "DDB_SCHEDULER_STEPFUNCTION", self.state_machine_arn + ) + self.function.add_to_role_policy( + iam.PolicyStatement( + actions=[ + "states:StartExecution", + "states:ListExecutions", + "states:StopExecution", + "states:DescribeExecution", + ], + effect=iam.Effect.ALLOW, + resources=[ + self.state_machine_arn, + self.state_machine_executions_arn, + ], + ) + ) + + self.scheduler_table.grant_read_write_data(self.function) + self.function.add_environment( + "DDB_SCHEDULES_TABLE", self.scheduler_table.table_name + ) diff --git a/source/infrastructure/personalize/scheduler/base.py b/source/infrastructure/personalize/scheduler/base.py new file mode 100644 index 0000000..401c1c6 --- /dev/null +++ b/source/infrastructure/personalize/scheduler/base.py @@ -0,0 +1,399 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from pathlib import Path +from typing import Union, Dict, List + +import aws_cdk.aws_dynamodb as ddb +import aws_cdk.aws_iam as iam +from aws_cdk.aws_lambda import Tracing +from aws_cdk.aws_stepfunctions import ( + StateMachine, + Chain, + Wait, + WaitTime, + IStateMachine, + CustomState, + TaskInput, + Choice, + Condition, +) +from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke +from aws_cdk.core import Construct, Aws + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name import ResourceName +from aws_solutions.cdk.aws_lambda.java.function import SolutionsJavaFunction +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression +from personalize.aws_lambda.functions.environment import Environment +from personalize.aws_lambda.layers import PowertoolsLayer, SolutionsLayer +from personalize.scheduler.aws_lambda.functions import ( + CreateScheduledTask, + ReadScheduledTask, + UpdateScheduledTask, + DeleteScheduledTask, +) + +TASK_NAME_PATH = "$.name" +TRIGGER_AT_PATH = "$.trigger_at" +SCHEDULE_PATH = "$.task.schedule" + + +class Scheduler(Construct): + """ + A Scheduler that leverages AWS Step Functions to invoke other AWS Step Functions on a specified cron() schedule. + + To manage tasks: + + 1. add the step function to manage through `grant_invoke` + 2. use the public CRUD methods of the Scheduler to set up Scheduler managed tasks + - create_scheduled_task.function (from another Step Function, use create_scheduled_task.state) + - read_scheduled_task.function (from another Step Function, use read_scheduled_task.state) + - update_scheduled_task.function (from another Step Function, use update_scheduled_task.state) + - delete_scheduled_task.function (from another Step Function, use delete_scheduled_task.state) + """ + + def __init__(self, scope: Construct, construct_id: str, sync: bool = True): + """ + Create a scheduler using AWS Step Functions + :param scope: the scope of this construct + :param construct_id: the ID of this construct + :param sync: synchronously invoke the scheduled item (otherwise, set to False for async) + """ + super().__init__(scope, construct_id) + + self.sync = sync + self.scheduler_function = self._scheduler_function(scope, "GetNextTimestamp") + self.scheduler_function_environment = Environment(self.scheduler_function) + self.scheduler_table = self._scheduler_table(scope) + + self._scheduler_child_state_machines: List[IStateMachine] = [] + self._state_machine_namer = ResourceName( + self, + "SchedulerStateMachineName", + purpose="personalize-scheduler", + max_length=80, + ) + + # Layers required for the AWS Lambda Functions provisioned by the Scheduler construct + layer_powertools = PowertoolsLayer.get_or_create(self) + layer_solutions = SolutionsLayer.get_or_create(self) + common_layers = [layer_powertools, layer_solutions] + + # CRUD tasks/ states to integrate with the Scheduler + self.create_scheduled_task = CreateScheduledTask( + self, + "create_scheduled_task", + layers=common_layers, + scheduler_table=self.scheduler_table, + state_machine_arn=self.state_machine_arn, + state_machine_executions_arn=self.state_machine_executions_arn, + ) + self.read_scheduled_task = ReadScheduledTask( + self, + "read_scheduled_task", + layers=common_layers, + scheduler_table=self.scheduler_table, + state_machine_arn=self.state_machine_arn, + ) + self.update_scheduled_task = UpdateScheduledTask( + self, + "update_scheudled_task", + layers=common_layers, + scheduler_table=self.scheduler_table, + state_machine_arn=self.state_machine_arn, + state_machine_executions_arn=self.state_machine_executions_arn, + ) + self.delete_scheduled_task = DeleteScheduledTask( + self, + "delete_scheduled_task", + layers=common_layers, + scheduler_table=self.scheduler_table, + state_machine_arn=self.state_machine_arn, + ) + + read_scheduled_task_state = self.read_scheduled_task.state( + self, + "Load Scheduled Task", + payload=TaskInput.from_object({"name.$": TASK_NAME_PATH}), + result_path="$.task", + ) + + get_next_trigger_time = self.get_trigger("Get Next Trigger Time") + + invoke_step_function = ( + Wait( + self, + "Wait Until Schedule Trigger", + time=WaitTime.timestamp_path(TRIGGER_AT_PATH), + ) + .next( + CustomState( + self, + "Invoke Step Function", + state_json=self._start_execution_task_json( + arn_to_invoke="$.task.state_machine.arn", + input="$.task.state_machine.input", + fallback=get_next_trigger_time, + ), + ) + ) + .next(get_next_trigger_time) + .next( + CustomState( + self, + "Run Next Scheduled Task", + state_json=self._start_execution_task_json( + arn_to_invoke=self.state_machine_arn, + input={ + "name.$": TASK_NAME_PATH, + "trigger_at.$": TRIGGER_AT_PATH, + }, + allow_sync=False, + ), + ) + ) + ) + + choice_get_next_trigger = Choice(self, "Trigger Time Provided?") + choice_get_next_trigger.when( + Condition.is_not_present(TRIGGER_AT_PATH), + self.get_trigger("Get Trigger Time"), + ) + choice_get_next_trigger.afterwards().next(invoke_step_function) + choice_get_next_trigger.otherwise(invoke_step_function) + + self._scheduler_definition = Chain.start( + read_scheduled_task_state.next(choice_get_next_trigger) + ) + + self.state_machine = StateMachine( + self, + "SchedulerStateMachine", + state_machine_name=self.state_machine_name, + definition=self._scheduler_definition, + tracing_enabled=True, + ) + + def grant_invoke(self, state_machine: IStateMachine) -> None: + """ + Allow the Scheduler to start executions of the provided state machine + :param state_machine: The state machine that the scheduler will start executions of + :return: None + """ + self._scheduler_child_state_machines.append(state_machine) + + def get_trigger(self, construct_id: str) -> LambdaInvoke: + """ + Get a task that returns the next trigger time from a cron schedule at $.schedule + :param construct_id: The name of the task + :return: the LambdaInvoke Task + """ + return LambdaInvoke( + self, + construct_id, + lambda_function=self.scheduler_function, + payload=TaskInput.from_object( + { + "schedule.$": SCHEDULE_PATH, + } + ), + result_path=TRIGGER_AT_PATH, + payload_response_only=True, + ) + + def _start_execution_task_json( + self, + arn_to_invoke: str, + input: Union[str, Dict], + fallback: LambdaInvoke = None, + allow_sync: bool = True, + ) -> Dict: + """ + Helper method to prepare the task input data for a states:startExecution task + :param arn_to_invoke: the state machine ARN to invoke + :param input: the input to provide to the state machine + :param allow_sync: whether to run sync or async (default: sync) + :param next: the next task to run + :return: Dict of the task properties + """ + state_machine_arn_property = "StateMachineArn" + if arn_to_invoke.startswith("$."): + state_machine_arn_property += ".$" + + input_property = "Input" + if isinstance(input, str) and input.startswith("$."): + input_property += ".$" + + if allow_sync and self.sync: + resource = "arn:aws:states:::states:startExecution.sync:2" + else: + resource = "arn:aws:states:::states:startExecution" + + task_json = { + "Type": "Task", + "Resource": resource, + "Parameters": { + state_machine_arn_property: arn_to_invoke, + input_property: input, + "Name.$": "$.task.next_task_id", + }, + "Retry": [{"ErrorEquals": ["StepFunctions.ExecutionLimitExceeded"]}], + "ResultPath": "$.startExecutionResult", # must output ; https://github.com/aws/aws-cdk/issues/8754 + "ResultSelector": {"ExecutionArn.$": "$.ExecutionArn"}, + } + if fallback: + task_json["Catch"] = [ + { + "ErrorEquals": ["States.TaskFailed"], + "Next": fallback.id, + "ResultPath": "$.startExecutionResult", + } + ] + return task_json + + @property + def state_machine_arn(self) -> str: + """ + Gets the state machine ARN for the scheduler state machine + :return: str + """ + return f"arn:{Aws.PARTITION}:states:{Aws.REGION}:{Aws.ACCOUNT_ID}:stateMachine:{self.state_machine_name}" + + @property + def state_machine_executions_arn(self) -> str: + return f"arn:{Aws.PARTITION}:states:{Aws.REGION}:{Aws.ACCOUNT_ID}:execution:{self.state_machine_name}:*" + + @property + def state_machine_name(self) -> str: + """ + Gets the state machine name for the scheduler state machine + :return: str + """ + return self._state_machine_namer.resource_name.to_string() + + def _prepare(self) -> None: + """ + Finalize/ prepare the state machine and associated permissions + :return: None + """ + # permision: allow the scheduler to call itself + self.state_machine.add_to_role_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + resources=[self.state_machine_arn], + actions=["states:StartExecution"], + ) + ) + if self.sync: + self.state_machine.add_to_role_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + resources=["*"], + actions=["states:DescribeExecution", "states:StopExecution"], + ) + ) + self.state_machine.add_to_role_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + resources=[ + f"arn:{Aws.PARTITION}:events:{Aws.REGION}:{Aws.ACCOUNT_ID}:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule" + ], + actions=[ + "events:PutTargets", + "events:PutRule", + "events:DescribeRule", + ], + ) + ) + + add_cfn_nag_suppressions( + self.state_machine.role.node.try_find_child( + "DefaultPolicy" + ).node.find_child("Resource"), + [ + CfnNagSuppression( + "W12", + "IAM policy for nested synchronous invocation of step functions requires * on Describe and Stop Execution", + ), + CfnNagSuppression( + "W76", + "Large step functions need larger IAM roles to access all managed AWS Lambda functions", + ), + ], + ) + + # permission: allow the scheduler to call its referenced children + for child in self._scheduler_child_state_machines: + child.grant_start_execution(self.state_machine) + + def _scheduler_table(self, scope: Construct) -> ddb.Table: + """ + Creates the table for tracking scheduled tasks managed by this Scheduler + :param scope: the scope of the construct (the scheduler) + :return: + """ + tasks_table = ddb.Table( + scope, + "ScheduledTasks", + point_in_time_recovery=True, + billing_mode=ddb.BillingMode.PAY_PER_REQUEST, + encryption=ddb.TableEncryption.AWS_MANAGED, + partition_key=ddb.Attribute( + name="name", + type=ddb.AttributeType.STRING, + ), + sort_key=ddb.Attribute( + name="version", + type=ddb.AttributeType.STRING, + ), + ) + tasks_table.node.default_child.override_logical_id("PersonalizeScheduledTasks") + + return tasks_table + + def _scheduler_function( + self, scope: Construct, construct_id: str + ) -> SolutionsJavaFunction: + """ + Creates the AWS Lambda Function for getting the next scheduled task time from a cron expression + :param scope: the scope of the function + :param construct_id: the construct ID of the function + :return: SolutionsJavaFunction + """ + project_path = ( + Path(__file__).absolute().parents[3] + / "aws_lambda" + / "get_next_scheduled_event" + ) + distribution_path = project_path / "build" / "distributions" + + function = SolutionsJavaFunction( + scope=scope, + construct_id=construct_id, + handler="com.amazonaws.solutions.schedule_sfn_task.HandleScheduleEvent", + project_path=project_path, + gradle_task="buildZip", + gradle_test="test", + distribution_path=distribution_path, + tracing=Tracing.ACTIVE, + ) + add_cfn_nag_suppressions( + function.role.node.try_find_child("DefaultPolicy").node.find_child( + "Resource" + ), + [ + CfnNagSuppression( + "W12", "IAM policy for AWS X-Ray requires an allow on *" + ) + ], + ) + return function diff --git a/source/infrastructure/personalize/sns/__init__.py b/source/infrastructure/personalize/sns/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/infrastructure/personalize/sns/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/infrastructure/personalize/sns/notifications.py b/source/infrastructure/personalize/sns/notifications.py new file mode 100644 index 0000000..0574f9c --- /dev/null +++ b/source/infrastructure/personalize/sns/notifications.py @@ -0,0 +1,84 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### +from typing import Optional + +from aws_cdk.aws_sns import Subscription, SubscriptionProtocol +from aws_cdk.aws_sns import TopicProps +from aws_cdk.aws_stepfunctions import IChainable +from aws_cdk.core import ( + Construct, + CfnParameter, + CfnCondition, + Aspects, +) +from aws_solutions_constructs.aws_lambda_sns import LambdaToSns + +from aws_solutions.cdk.aspects import ConditionalResources +from personalize.aws_lambda.functions.solutionstep import SolutionStep + + +class Notifications(SolutionStep): + def __init__( + self, + scope: Construct, + id: str, + email: CfnParameter, + email_provided: CfnCondition, + layers=None, + failure_state: Optional[IChainable] = None, + ): + self.email = email + self.email_provided = email_provided + self.topic = None # delay creation until after parent is setup + self.subscription = None # delay creation until after parent is setup + + super().__init__(scope, id, layers=layers, failure_state=failure_state) + + def create_sns(self): + """ + Create the SNS topic using AWS Solutions Constructs + :return: + """ + lambda_sns = LambdaToSns( + self, + "NotificationConfiguration", + existing_lambda_obj=self.function, + topic_props=TopicProps( + display_name=f"{self.node.try_get_context('SOLUTION_NAME')} Notifications" + ), + ) + topic = lambda_sns.sns_topic + topic.node.default_child.override_logical_id("NotificationTopic") + return topic + + def create_subscription(self, email, email_provided): + logical_id = "NotificationSubscription" + subscription = Subscription( + self, + logical_id, + topic=self.topic, + endpoint=email.value_as_string, + protocol=SubscriptionProtocol.EMAIL, + ) + subscription.node.default_child.override_logical_id(logical_id) + Aspects.of(subscription).add(ConditionalResources(email_provided)) + return subscription + + def _create_resources(self): + self.topic = self.create_sns() + self.subscription = self.create_subscription( + email=self.email, email_provided=self.email_provided + ) + + def _set_permissions(self) -> None: + self.topic.grant_publish(self.function) diff --git a/source/infrastructure/personalize/stack.py b/source/infrastructure/personalize/stack.py new file mode 100644 index 0000000..a28901c --- /dev/null +++ b/source/infrastructure/personalize/stack.py @@ -0,0 +1,415 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from aws_cdk import core as cdk +from aws_cdk.aws_s3 import EventType, NotificationKeyFilter +from aws_cdk.aws_s3_notifications import LambdaDestination +from aws_cdk.aws_stepfunctions import ( + StateMachine, + Chain, + Parallel, + TaskInput, +) +from aws_cdk.core import CfnCondition, Fn, Aws, Duration + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name import ResourceName +from aws_solutions.cdk.cfn_nag import ( + CfnNagSuppression, + add_cfn_nag_suppressions, + CfnNagSuppressAll, +) +from aws_solutions.cdk.stack import SolutionStack +from personalize.aws_lambda.functions import ( + S3EventHandler, + CreateDatasetGroup, + CreateSchema, + CreateDataset, + CreateDatasetImportJob, + CreateEventTracker, + CreateSolution, + CreateSolutionVersion, + CreateCampaign, + CreateFilter, + CreateBatchInferenceJob, + CreateTimestamp, +) +from personalize.aws_lambda.layers import PowertoolsLayer, SolutionsLayer +from personalize.cloudwatch.dashboard import Dashboard +from personalize.s3 import AccessLogsBucket, DataBucket +from personalize.scheduler import Scheduler +from personalize.sns.notifications import Notifications +from personalize.step_functions.dataset_imports_fragment import DatasetImportsFragment +from personalize.step_functions.event_tracker_fragment import EventTrackerFragment +from personalize.step_functions.failure_fragment import FailureFragment +from personalize.step_functions.filter_fragment import FilterFragment +from personalize.step_functions.scheduled_dataset_import import ScheduledDatasetImport +from personalize.step_functions.scheduled_solution_maintenance import ( + ScheduledSolutionMaintenance, +) +from personalize.step_functions.scheduler_fragment import SchedulerFragment +from personalize.step_functions.schedules import Schedules +from personalize.step_functions.solution_fragment import SolutionFragment + + +class PersonalizeStack(SolutionStack): + def __init__( + self, scope: cdk.Construct, construct_id: str, *args, **kwargs + ) -> None: + super().__init__(scope, construct_id, *args, **kwargs) + + # CloudFormation Parameters + self.personalize_kms_key_arn = cdk.CfnParameter( + self, + id="PersonalizeKmsKeyArn", + description="Provide Amazon Personalize with an alternate AWS Key Management (KMS) key to use to encrypt your datasets", + default="", + allowed_pattern="(^arn:.*:kms:.*:.*:key/.*$|^$)", + ) + self.solutions_template_options.add_parameter( + self.personalize_kms_key_arn, + "(Optional) KMS key ARN used to encrypt Datasets managed by Amazon Personalize", + "Security Configuration", + ) + kms_enabled = cdk.CfnCondition( + self, + "PersonalizeSseKmsEnabled", + expression=Fn.condition_not( + Fn.condition_equals(self.personalize_kms_key_arn, "") + ), + ) + + self.email = cdk.CfnParameter( + self, + id="Email", + type="String", + description="Email to notify with personalize workflow results", + default="", + max_length=50, + allowed_pattern=r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$|^$)", + constraint_description="Must be a valid email address or blank", + ) + self.solutions_template_options.add_parameter( + self.email, "Email", "Solution Configuration" + ) + self.email_provided = CfnCondition( + self, + "EmailProvided", + expression=Fn.condition_not(Fn.condition_equals(self.email, "")), + ) + + # layers + layer_powertools = PowertoolsLayer.get_or_create(self) + layer_solutions = SolutionsLayer.get_or_create(self) + common_layers = [layer_powertools, layer_solutions] + + # buckets + access_logs_bucket = AccessLogsBucket( + self, + "AccessLogsBucket", + suppress=[ + CfnNagSuppression( + "W35", + "This bucket is used as the logging destination for personalization data processing", + ) + ], + ) + + data_bucket = DataBucket( + self, + "PersonalizeBucket", + server_access_logs_bucket=access_logs_bucket, + server_access_logs_prefix="personalize-bucket-access-logs/", + ) + + # the AWS lambda functions required by the shared step functions + create_dataset_group = CreateDatasetGroup( + self, + "Create Dataset Group", + input_path="$.datasetGroup", # NOSONAR (python:S1192) - string for clarity + result_path="$.datasetGroup.serviceConfig", # NOSONAR (python:S1192) - string for clarity + kms_enabled=kms_enabled, + kms_key=self.personalize_kms_key_arn, + layers=common_layers, + ) + create_schema = CreateSchema( + self, + "Create Schema", + layers=common_layers, + ) + create_dataset = CreateDataset( + self, + "Create Dataset", + layers=common_layers, + ) + create_dataset_import_job = CreateDatasetImportJob( + self, + "Create Dataset Import Job", + layers=common_layers, + personalize_bucket=data_bucket, + ) + notifications = Notifications( + self, + "SNS Notification", + email=self.email, + email_provided=self.email_provided, + layers=common_layers, + ) + create_event_tracker = CreateEventTracker( + self, + "Create Event Tracker", + layers=common_layers, + ) + create_solution = CreateSolution( + self, + "Create Solution", + layers=common_layers, + ) + create_solution_version = CreateSolutionVersion( + self, + "Create Solution Version", + layers=common_layers, + ) + create_campaign = CreateCampaign( + self, + "Create Campaign", + layers=common_layers, + ) + create_batch_inference_job = CreateBatchInferenceJob( + self, + "Create Batch Inference Job", + layers=common_layers, + personalize_bucket=data_bucket, + ) + create_filter = CreateFilter(self, "Create Filter", layers=common_layers) + create_timestamp = CreateTimestamp( + self, "Create Timestamp", layers=[layer_powertools] + ) + + dataset_management_functions = { + "create_schema": create_schema, + "create_dataset": create_dataset, + "create_dataset_import_job": create_dataset_import_job, + } + + success = notifications.state( + self, + "Success", + payload=TaskInput.from_object( + {"datasetGroup.$": "$[0].datasetGroup.serviceConfig.name"} + ), + ) + + dataset_import_schedule_sfn = ScheduledDatasetImport( + self, + "Scheduled Dataset Import", + dataset_management_functions=dataset_management_functions, + create_timestamp=create_timestamp, + notifications=notifications, + ).state_machine + + solution_maintenance_schedule_sfn = ScheduledSolutionMaintenance( + self, + "Scheduled Solution Maintenance", + create_solution=create_solution, + create_solution_version=create_solution_version, + create_campaign=create_campaign, + create_batch_inference_job=create_batch_inference_job, + create_timestamp=create_timestamp, + notifications=notifications, + ).state_machine + + # scheduler and step function to schedule + scheduler = Scheduler(self, "Scheduler") + scheduler.grant_invoke(dataset_import_schedule_sfn) + scheduler.grant_invoke(solution_maintenance_schedule_sfn) + + schedules = Schedules( + dataset_import=SchedulerFragment( + self, + schedule_for="personalize dataset import", + schedule_for_suffix="$.datasetGroup.serviceConfig.name", + scheduler=scheduler, + target=dataset_import_schedule_sfn, + schedule_path="$.datasetGroup.workflowConfig.schedules.import", + schedule_input={ + "datasetGroup": { + "serviceConfig.$": "$.datasetGroup.serviceConfig", + "workflowConfig": {"maxAge": "1 second"}, + }, # NOSONAR (python:S1192) - string for clarity + "datasets.$": "$.datasets", + "bucket.$": "$.bucket", + }, + ), + ) + + create_solutions = SolutionFragment( + self, + "Create Solutions", + create_solution=create_solution, + create_solution_version=create_solution_version, + create_campaign=create_campaign, + create_batch_inference_job=create_batch_inference_job, + scheduler=scheduler, + to_schedule=solution_maintenance_schedule_sfn, + ) + + # fmt: off + definition = Chain.start( + Parallel(self, "Manage The Execution") + .branch( + create_dataset_group.state( + self, + "Create Dataset Group", + backoff_rate=1.02, + interval=Duration.seconds(5), + max_attempts=30, + ) + .next( + DatasetImportsFragment( + self, + "Handle Dataset Imports", + **dataset_management_functions + ) + ).next( + schedules.dataset_import + ).next( + EventTrackerFragment(self, "Event Tracker", create_event_tracker=create_event_tracker) + ).next( + FilterFragment(self, "Filters", create_filter=create_filter) # filters require data to be present + ).next( + create_solutions + ) + ) + .add_catch( + FailureFragment(self, notifications).start_state, + errors=["States.ALL"], + result_path="$.statesError" + ) + .next(success) + ) + # fmt: on + + state_machine_namer = ResourceName( + self, "StateMachineName", purpose="personalize-workflow", max_length=80 + ) + state_machine = StateMachine( + self, + "PersonalizeStateMachine", + state_machine_name=state_machine_namer.resource_name.to_string(), + definition=definition, + tracing_enabled=True, + ) + add_cfn_nag_suppressions( + state_machine.role.node.try_find_child("DefaultPolicy").node.find_child( + "Resource" + ), + [ + CfnNagSuppression( + "W12", "IAM policy for AWS X-Ray requires an allow on *" + ), + CfnNagSuppression( + "W76", + "Large step functions need larger IAM roles to access all managed AWS Lambda functions", + ), + ], + ) + + s3_event_handler = S3EventHandler( + self, + "S3EventHandler", + state_machine=state_machine, + bucket=data_bucket, + layers=[layer_powertools, layer_solutions], + topic=notifications.topic, + ) + s3_event_notification = LambdaDestination(s3_event_handler) + data_bucket.add_event_notification( + EventType.OBJECT_CREATED, + s3_event_notification, + NotificationKeyFilter(prefix="train/", suffix=".json"), + ) + + # Handle suppressions for the notification handler resource generated by CDK + bucket_notification_handler = self.node.try_find_child( + "BucketNotificationsHandler050a0587b7544547bf325f094a3db834" + ) + bucket_notification_policy = ( + bucket_notification_handler.node.find_child("Role") + .node.find_child("DefaultPolicy") + .node.find_child("Resource") + ) + add_cfn_nag_suppressions( + bucket_notification_policy, + [ + CfnNagSuppression( + "W12", + "bucket resource is '*' due to circular dependency with bucket and role creation at the same time", + ) + ], + ) + + cdk.Tags.of(self).add("SOLUTION_ID", self.node.try_get_context("SOLUTION_ID")) + cdk.Tags.of(self).add( + "SOLUTION_NAME", self.node.try_get_context("SOLUTION_NAME") + ) + cdk.Tags.of(self).add( + "SOLUTION_VERSION", self.node.try_get_context("SOLUTION_VERSION") + ) + + cdk.Aspects.of(self).add( + CfnNagSuppressAll( + suppress=[ + CfnNagSuppression( + "W89", + "functions deployed by this solution do not require VPC access", + ), + CfnNagSuppression( + "W92", + "functions deployed by this solution do not require reserved concurrency", + ), + CfnNagSuppression( + "W58", + "functions deployed by this solution use custom policy to write to CloudWatch logs", + ), + ], + resource_type="AWS::Lambda::Function", + ) + ) + + # dashboard + self.dashboard = Dashboard( + self, + "PersonalizeDashboard", + scheduler_sfn_arn=scheduler.state_machine_arn, + personalize_bucket_name=data_bucket.bucket_name, + ) + + # outputs + cdk.CfnOutput( + self, + "PersonalizeBucketName", + value=data_bucket.bucket_name, + export_name=f"{Aws.STACK_NAME}-PersonalizeBucketName", + ) + cdk.CfnOutput( + self, + "SchedulerTableName", + value=scheduler.scheduler_table.table_name, + export_name=f"{Aws.STACK_NAME}-SchedulerTableName", + ) + cdk.CfnOutput( + self, + "Dashboard", + value=self.dashboard.name, + export_name=f"{Aws.STACK_NAME}-Dashboard", + ) diff --git a/source/infrastructure/personalize/step_functions/__init__.py b/source/infrastructure/personalize/step_functions/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/infrastructure/personalize/step_functions/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/infrastructure/personalize/step_functions/batch_inference_jobs_fragment.py b/source/infrastructure/personalize/step_functions/batch_inference_jobs_fragment.py new file mode 100644 index 0000000..4ae83f7 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/batch_inference_jobs_fragment.py @@ -0,0 +1,178 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import List, Optional + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + State, + INextable, + Choice, + Pass, + Map, + Condition, + Chain, + StateMachine, +) +from aws_cdk.core import Construct, Duration + +from personalize.aws_lambda.functions import ( + CreateBatchInferenceJob, +) +from personalize.scheduler import Scheduler +from personalize.step_functions.scheduler_fragment import SchedulerFragment + +TEMPORARY_PATH = "$._tmp" +BATCH_INFERENCE_JOB_PATH = "$.batchInferenceJob" +BUCKET_PATH = "$.bucket" +CURRENT_DATE_PATH = "$.currentDate" + + +class BatchInferenceJobsFragment(StateMachineFragment): + def __init__( + self, + scope: Construct, + id: str, + create_batch_inference_job: CreateBatchInferenceJob, + scheduler: Optional[Scheduler] = None, + to_schedule: Optional[StateMachine] = None, + ): + super().__init__(scope, id) + + # total allowed elapsed duration ~ 5h + retry_config = { + "backoff_rate": 1.02, + "interval": Duration.seconds(60), + "max_attempts": 100, + } + + self.batch_inference_jobs_not_available = Pass( + self, "Batch Inference Jobs Not Provided" + ) + batch_inference_jobs_available = Choice( + self, "Check for Batch Inference Jobs" + ).otherwise(self.batch_inference_jobs_not_available) + + _prepare_batch_inference_job_input_job_name = Pass( + self, + "Set Batch Inference Job Input Data - Job Name", + input_path="$.batchInferenceJobName", + result_path=f"{BATCH_INFERENCE_JOB_PATH}.serviceConfig.jobName", + ) + + _prepare_batch_inference_job_input_solution_version_arn = Pass( + self, + "Set Batch Inference Job Input Data - Solution Version ARN", + input_path="$.solutionVersionArn", # NOSONAR (python:S1192) - string for clarity + result_path=f"{BATCH_INFERENCE_JOB_PATH}.serviceConfig.solutionVersionArn", + ) + + _prepare_batch_inference_job_job_input = Pass( + self, + "Set Batch Inference Job Input Data - Job Input", + result_path=f"{BATCH_INFERENCE_JOB_PATH}.serviceConfig.jobInput", + parameters={ + "s3DataSource": { + "path.$": f"States.Format('s3://{{}}/batch/{{}}/{{}}/job_config.json', $.bucket.name, $.datasetGroupName, $.solution.serviceConfig.name)" # NOSONAR (python:S1192) - string for clarity + } + }, + ) + + _prepare_batch_inference_job_job_output = Pass( + self, + "Set Batch Inference Job Input Data - Job Output", + result_path=f"{BATCH_INFERENCE_JOB_PATH}.serviceConfig.jobOutput", + parameters={ + "s3DataDestination": { + "path.$": f"States.Format('s3://{{}}/batch/{{}}/{{}}/{{}}/', $.bucket.name, $.datasetGroupName, $.solution.serviceConfig.name, $.batchInferenceJobName)" # NOSONAR (python:S1192) - string for clarity + } + }, + ) + + _prepare_batch_inference_job_input = Chain.start( + _prepare_batch_inference_job_input_job_name.next( + _prepare_batch_inference_job_input_solution_version_arn + ) + .next(_prepare_batch_inference_job_job_input) + .next(_prepare_batch_inference_job_job_output) + ) + + _create_batch_inference_job = create_batch_inference_job.state( + self, + "Create Batch Inference Job", + result_path=f"{BATCH_INFERENCE_JOB_PATH}.serviceConfig", + input_path=f"{BATCH_INFERENCE_JOB_PATH}", + **retry_config, + ) + if scheduler and to_schedule: + _create_batch_inference_job.next( + SchedulerFragment( + self, + schedule_for="batch inference", + schedule_for_suffix="$.solution.serviceConfig.name", # NOSONAR (python:S1192) - string for clarity + scheduler=scheduler, + target=to_schedule, + schedule_path="$.batchInferenceJob.workflowConfig.schedule", + schedule_input={ + "bucket.$": "$.bucket", + "datasetGroup": { + "serviceConfig": { + "name.$": "$.datasetGroupName", + "datasetGroupArn.$": "$.datasetGroupArn", + } + }, + "solutions": [ + { + "serviceConfig.$": "$.solution.serviceConfig", + "batchInferenceJobs": [ + { + "serviceConfig.$": "$.batchInferenceJob.serviceConfig", + "workflowConfig": {"maxAge": "1 second"}, + } + ], + } + ], + }, + ) + ) + + self.create_batch_inference_jobs = batch_inference_jobs_available.when( + Condition.is_present("$.solution.batchInferenceJobs[0]"), + Map( + self, + "Create Batch Inference Jobs", + items_path="$.solution.batchInferenceJobs", + parameters={ + "solutionVersionArn.$": "$.solution.solutionVersion.serviceConfig.solutionVersionArn", + "batchInferenceJob.$": "$$.Map.Item.Value", + "batchInferenceJobName.$": f"States.Format('batch_{{}}_{{}}', $.solution.serviceConfig.name, {CURRENT_DATE_PATH})", + "bucket.$": BUCKET_PATH, # NOSONAR (python:S1192) - string for clarity + "currentDate.$": CURRENT_DATE_PATH, # NOSONAR (python:S1192) - string for clarity + "datasetGroupName.$": "$.datasetGroupName", + "datasetGroupArn.$": "$.datasetGroupArn", + "solution.$": "$.solution", + }, + ).iterator( + _prepare_batch_inference_job_input.next(_create_batch_inference_job) + ), + ) + + @property + def start_state(self) -> State: + return self.create_batch_inference_jobs.start_state + + @property + def end_states(self) -> List[INextable]: + return [ + self.create_batch_inference_jobs.start_state, + self.batch_inference_jobs_not_available, + ] diff --git a/source/infrastructure/personalize/step_functions/dataset_import_fragment.py b/source/infrastructure/personalize/step_functions/dataset_import_fragment.py new file mode 100644 index 0000000..f4d1141 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/dataset_import_fragment.py @@ -0,0 +1,123 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import List + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + State, + TaskInput, + INextable, + Choice, + Condition, + JsonPath, + Pass, +) +from aws_cdk.core import Construct, Duration + +from personalize.aws_lambda.functions import ( + CreateDataset, + CreateSchema, + CreateDatasetImportJob, +) + + +class DatasetImportFragment(StateMachineFragment): + def __init__( + self, + scope: Construct, + id: str, + create_schema: CreateSchema, + create_dataset: CreateDataset, + create_dataset_import_job: CreateDatasetImportJob, + ): + super().__init__(scope, id) + self.create_schema = create_schema + self.create_dataset = create_dataset + self.create_dataset_import_job = create_dataset_import_job + + # total allowed elapsed duration ~ 5h + retry_config = { + "backoff_rate": 1.02, + "interval": Duration.seconds(60), + "max_attempts": 100, + } + + # fmt: off + na_state = Pass(self, f"{id} Not Provided", result_path=JsonPath.DISCARD) + self._choice = Choice(self, f"Check if {id} Data Configuration Present") + + import_input = { + "jobName.$": f"States.Format('dataset_import_{id.lower()}_{{}}', $.currentDate)", + "datasetArn.$": f"$.datasets.{id.lower()}.dataset.serviceConfig.datasetArn", + } + import_datasets_from_csv = create_dataset_import_job.state(self, f"Try {id} Dataset Import from CSV", + payload=TaskInput.from_object({ + "serviceConfig": { + **import_input, + "dataSource": { + "dataLocation.$": f"States.Format('s3://{{}}/{{}}/{id.lower()}.csv', $.bucket.name, $.bucket.key)" # NOSONAR (python:S1192) - string for clarity + }, + }, + "workflowConfig": { + "maxAge.$": "$.datasetGroup.workflowConfig.maxAge" + } + }), + result_path=JsonPath.DISCARD, + **retry_config) + import_datasets_from_csv.start_state.add_catch( + na_state, errors=["NoSuchKey"], result_path=JsonPath.DISCARD + ) + import_datasets_from_prefix = create_dataset_import_job.state(self, f"Try {id} Dataset Import from Prefix", + payload=TaskInput.from_object({ + "serviceConfig": { + **import_input, + "dataSource": { + "dataLocation.$": f"States.Format('s3://{{}}/{{}}/{id.lower()}', $.bucket.name, $.bucket.key)" # NOSONAR (python:S1192) - string for clarity + }, + }, + "workflowConfig": { + "maxAge.$": "$.datasetGroup.workflowConfig.maxAge" + } + }), + result_path=JsonPath.DISCARD, + **retry_config) + import_datasets_from_prefix.start_state.add_catch( + import_datasets_from_csv, errors=["NoSuchKey"], result_path=JsonPath.DISCARD + ) + + self._choice.when(Condition.is_present(f"$.datasets.{id.lower()}"), + create_schema.state(self, f"Create {id} Schema", + input_path=f"$.datasets.{id.lower()}.schema", + result_path=f"$.datasets.{id.lower()}.schema.serviceConfig") + .next(create_dataset.state(self, f"Create {id} Dataset", + payload=TaskInput.from_object({ + "name.$": f"$.datasets.{id.lower()}.dataset.serviceConfig.name", + "schemaArn.$": f"$.datasets.{id.lower()}.schema.serviceConfig.schemaArn", + "datasetGroupArn.$": "$.datasetGroup.serviceConfig.datasetGroupArn", + "datasetType": f"{id.lower()}", + }), + result_path=f"$.datasets.{id.lower()}.dataset.serviceConfig", + **retry_config)) + .next(import_datasets_from_prefix)) + self._choice.otherwise( + na_state + ) + # fmt: on + + @property + def start_state(self) -> State: + return self._choice + + @property + def end_states(self) -> List[INextable]: + return [self._choice] diff --git a/source/infrastructure/personalize/step_functions/dataset_imports_fragment.py b/source/infrastructure/personalize/step_functions/dataset_imports_fragment.py new file mode 100644 index 0000000..180aff2 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/dataset_imports_fragment.py @@ -0,0 +1,59 @@ +from typing import List + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + Chain, + Parallel, + JsonPath, + State, + INextable, +) +from aws_cdk.core import Construct + +from personalize.aws_lambda.functions import ( + CreateSchema, + CreateDataset, + CreateDatasetImportJob, +) +from personalize.step_functions.dataset_import_fragment import DatasetImportFragment + + +class DatasetImportsFragment(StateMachineFragment): + def __init__( + self, + scope: Construct, + construct_id: str, + create_schema: CreateSchema, + create_dataset: CreateDataset, + create_dataset_import_job: CreateDatasetImportJob, + ): + super().__init__(scope, construct_id) + + dataset_management_functions = { + "create_schema": create_schema, + "create_dataset": create_dataset, + "create_dataset_import_job": create_dataset_import_job, + } + + self.chain = Chain.start( + Parallel(self, "Create and Import Datasets", result_path=JsonPath.DISCARD) + .branch( + DatasetImportFragment( + self, "Interactions", **dataset_management_functions + ) + ) + .branch( + DatasetImportFragment(self, "Users", **dataset_management_functions) + ) + .branch( + DatasetImportFragment(self, "Items", **dataset_management_functions) + ) + ) + + @property + def start_state(self) -> State: + return self.chain.start_state + + @property + def end_states(self) -> List[INextable]: + return self.chain.end_states diff --git a/source/infrastructure/personalize/step_functions/event_tracker_fragment.py b/source/infrastructure/personalize/step_functions/event_tracker_fragment.py new file mode 100644 index 0000000..d359778 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/event_tracker_fragment.py @@ -0,0 +1,67 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import List + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + State, + INextable, + Choice, + Pass, + Condition, +) +from aws_cdk.core import Construct, Duration + +from personalize.aws_lambda.functions import ( + CreateEventTracker, +) + + +class EventTrackerFragment(StateMachineFragment): + def __init__( + self, + scope: Construct, + id: str, + create_event_tracker: CreateEventTracker, + ): + super().__init__(scope, id) + + # total allowed elapsed duration ~ 11m30s + retry_config = { + "backoff_rate": 1.25, + "interval": Duration.seconds(8), + "max_attempts": 15, + } + + self.create_event_tracker = create_event_tracker.state( + self, + "Create Event Tracker", + **retry_config, + ) + self.not_required = Pass(self, "Event Tracker not Required") + self.start = ( + Choice(self, "Check if Event Tracker Required") + .when( + Condition.is_present("$.eventTracker.serviceConfig.name"), + self.create_event_tracker, + ) + .otherwise(self.not_required) + ) + + @property + def start_state(self) -> State: + return self.start.start_state + + @property + def end_states(self) -> List[INextable]: + return [self.not_required, self.create_event_tracker] diff --git a/source/infrastructure/personalize/step_functions/failure_fragment.py b/source/infrastructure/personalize/step_functions/failure_fragment.py new file mode 100644 index 0000000..532a159 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/failure_fragment.py @@ -0,0 +1,57 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import List + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + State, + INextable, + Fail, + TaskInput, +) +from aws_cdk.core import Construct + +from personalize.sns.notifications import Notifications + + +class FailureFragment(StateMachineFragment): + def __init__( + self, + scope: Construct, + notifications: Notifications, + construct_id: str = "Failure", + ): + if construct_id != "Failure": + construct_id = " ".join([construct_id, "Failure"]).strip() + super().__init__(scope, construct_id) + + self.failure_state = Fail(self, construct_id) + + self.notification_state = notifications.state( + self, + construct_id=f"Send {construct_id} Message", + payload=TaskInput.from_object( + { + "datasetGroup.$": "$.datasetGroup.serviceConfig.name", + "statesError.$": "$.statesError", + } + ), + ).next(self.failure_state) + + @property + def start_state(self) -> State: + return self.notification_state + + @property + def end_states(self) -> List[INextable]: + return [self.failure_state] diff --git a/source/infrastructure/personalize/step_functions/filter_fragment.py b/source/infrastructure/personalize/step_functions/filter_fragment.py new file mode 100644 index 0000000..ea4b0d2 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/filter_fragment.py @@ -0,0 +1,88 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import List + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + State, + INextable, + Choice, + Pass, + Condition, + Map, + JsonPath, +) +from aws_cdk.core import Construct, Duration + +from personalize.aws_lambda.functions import ( + CreateFilter, +) + + +class FilterFragment(StateMachineFragment): + def __init__( + self, + scope: Construct, + id: str, + create_filter: CreateFilter, + ): + super().__init__(scope, id) + + # total allowed elapsed duration ~ 11m30s + retry_config = { + "backoff_rate": 1.25, + "interval": Duration.seconds(8), + "max_attempts": 15, + } + + self.prepare_filter_input = Pass( + self, + "Prepare Filter Input Data", + input_path="$.datasetGroupArn", + result_path="$.filter.serviceConfig.datasetGroupArn", + ) + self.create_filter = create_filter.state( + self, + "Create Filter", + input_path="$.filter", + **retry_config, + ) + self.not_required = Pass(self, "Filters Not Required") + self.create_filters = Map( + self, + "Create Filters", + items_path="$.filters", + parameters={ + "datasetGroupArn.$": "$.datasetGroup.serviceConfig.datasetGroupArn", + "filter.$": "$$.Map.Item.Value", + }, + result_path=JsonPath.DISCARD, + ) + self.start = ( + Choice(self, "Check if Filters Required") + .when( + Condition.is_present("$.filters[0]"), + self.create_filters.iterator( + self.prepare_filter_input.next(self.create_filter) + ), + ) + .otherwise(self.not_required) + ) + + @property + def start_state(self) -> State: + return self.start.start_state + + @property + def end_states(self) -> List[INextable]: + return [self.not_required, self.create_filters] diff --git a/source/infrastructure/personalize/step_functions/personalization_fragment.py b/source/infrastructure/personalize/step_functions/personalization_fragment.py new file mode 100644 index 0000000..4fecd16 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/personalization_fragment.py @@ -0,0 +1,81 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import List, Dict +from typing import Optional + +from aws_cdk.aws_lambda import CfnFunction +from aws_cdk.aws_stepfunctions import State, INextable, TaskInput, StateMachineFragment +from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke +from aws_cdk.core import Construct, Duration + + +class PersonalizationFragment(StateMachineFragment): + def __init__( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + id: str, + function: CfnFunction, + payload: Optional[TaskInput] = None, + input_path: Optional[str] = "$", + result_path: Optional[str] = "$", + output_path: Optional[str] = "$", + result_selector: Optional[Dict] = None, + failure_state: Optional[State] = None, + backoff_rate: Optional[int] = 1.05, + interval: Optional[Duration] = Duration.seconds(5), + max_attempts: Optional[int] = 5, + ): + super().__init__(scope, id) + + self.failure_state = failure_state + + self.task = LambdaInvoke( + self, + id, + lambda_function=function, + retry_on_service_exceptions=True, + input_path=input_path, + result_path=result_path, + output_path=output_path, + payload=payload, + payload_response_only=True, + result_selector=result_selector, + ) + self.task.add_retry( + backoff_rate=backoff_rate, + interval=interval, + max_attempts=max_attempts, + errors=["ResourcePending"], + ) + if self.failure_state: + self.task.add_catch( + failure_state, + errors=["ResourceFailed", "ResourceInvalid"], + result_path="$.statesError", + ) + self.task.add_catch( + failure_state, errors=["States.ALL"], result_path="$.statesError" + ) + + @property + def start_state(self) -> State: + return self.task + + @property + def end_states(self) -> List[INextable]: + """ + Get the end states of this chain + :return: The chainable end states of this chain (i.e. not the failure state) + """ + states = [self.task] + return states diff --git a/source/infrastructure/personalize/step_functions/scheduled_dataset_import.py b/source/infrastructure/personalize/step_functions/scheduled_dataset_import.py new file mode 100644 index 0000000..9deeab9 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/scheduled_dataset_import.py @@ -0,0 +1,85 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import Dict + +from aws_cdk.aws_stepfunctions import StateMachine, Chain, Parallel, TaskInput +from aws_cdk.core import Construct + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name import ResourceName +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression +from personalize.aws_lambda.functions.solutionstep import SolutionStep +from personalize.step_functions.dataset_imports_fragment import DatasetImportsFragment +from personalize.step_functions.failure_fragment import FailureFragment + + +class ScheduledDatasetImport(Construct): + def __init__( + self, + scope: Construct, + construct_id: str, + dataset_management_functions: Dict[str, SolutionStep], + create_timestamp: SolutionStep, + notifications: SolutionStep, + ): + super().__init__(scope, construct_id) + + state_machine_namer = ResourceName( + self, "StateMachineName", purpose="periodic-dataset-import", max_length=80 + ) + self.state_machine = StateMachine( + self, + "PeriodicDatasetImport", + state_machine_name=state_machine_namer.resource_name.to_string(), + definition=Chain.start( + Parallel(self, "Manage The Execution") + .branch( + create_timestamp.state( + self, "Set Current Timestamp", result_path="$.currentDate" + ).next( + DatasetImportsFragment( + self, + "Handle Periodic Dataset Imports", + **dataset_management_functions + ) + ) + ) + .add_catch( + FailureFragment(self, notifications).start_state, + errors=["States.ALL"], + result_path="$.statesError", + ) + .next( + notifications.state( + self, + "Success", + payload=TaskInput.from_object( + {"datasetGroup.$": "$[0].datasetGroup.serviceConfig.name"} + ), + ) + ) + ), + ) + add_cfn_nag_suppressions( + self.state_machine.role.node.try_find_child( + "DefaultPolicy" + ).node.find_child("Resource"), + [ + CfnNagSuppression( + "W12", "IAM policy for AWS X-Ray requires an allow on *" + ), + CfnNagSuppression( + "W76", + "Large step functions need larger IAM roles to access all managed AWS Lambda functions", + ), + ], + ) diff --git a/source/infrastructure/personalize/step_functions/scheduled_solution_maintenance.py b/source/infrastructure/personalize/step_functions/scheduled_solution_maintenance.py new file mode 100644 index 0000000..57318da --- /dev/null +++ b/source/infrastructure/personalize/step_functions/scheduled_solution_maintenance.py @@ -0,0 +1,99 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from aws_cdk.aws_stepfunctions import StateMachine, Chain, Parallel, TaskInput +from aws_cdk.core import Construct + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name import ResourceName +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression +from personalize.aws_lambda.functions import ( + CreateBatchInferenceJob, + CreateSolution, + CreateSolutionVersion, + CreateCampaign, +) +from personalize.aws_lambda.functions.solutionstep import SolutionStep +from personalize.step_functions.failure_fragment import FailureFragment +from personalize.step_functions.solution_fragment import SolutionFragment + + +class ScheduledSolutionMaintenance(Construct): + def __init__( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + construct_id: str, + create_solution: CreateSolution, + create_solution_version: CreateSolutionVersion, + create_campaign: CreateCampaign, + create_batch_inference_job: CreateBatchInferenceJob, + create_timestamp: SolutionStep, + notifications: SolutionStep, + ): + super().__init__(scope, construct_id) + + state_machine_namer = ResourceName( + self, + "StateMachineName", + purpose="periodic-solution-maintenance", + max_length=80, + ) + self.state_machine = StateMachine( + self, + "PeriodicSolutionMaintenance", + state_machine_name=state_machine_namer.resource_name.to_string(), + definition=Chain.start( + Parallel(self, "Manage Solution Maintenance") + .branch( + create_timestamp.state( + self, "Set Current Timestamp", result_path="$.currentDate" + ).next( + SolutionFragment( + self, + "Handle Periodic Solution Maintenance", + create_solution=create_solution, + create_solution_version=create_solution_version, + create_campaign=create_campaign, + create_batch_inference_job=create_batch_inference_job, + ) + ) + ) + .add_catch( + FailureFragment(self, notifications).start_state, + errors=["States.ALL"], + result_path="$.statesError", + ) + .next( + notifications.state( + self, + "Success", + payload=TaskInput.from_object( + {"datasetGroup.$": "$[0].datasetGroup.serviceConfig.name"} + ), + ) + ) + ), + ) + add_cfn_nag_suppressions( + self.state_machine.role.node.try_find_child( + "DefaultPolicy" + ).node.find_child("Resource"), + [ + CfnNagSuppression( + "W12", "IAM policy for AWS X-Ray requires an allow on *" + ), + CfnNagSuppression( + "W76", + "Large step functions need larger IAM roles to access all managed AWS Lambda functions", + ), + ], + ) diff --git a/source/infrastructure/personalize/step_functions/scheduler_fragment.py b/source/infrastructure/personalize/step_functions/scheduler_fragment.py new file mode 100644 index 0000000..cab5af8 --- /dev/null +++ b/source/infrastructure/personalize/step_functions/scheduler_fragment.py @@ -0,0 +1,91 @@ +# ###################################################################################################################### +# 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 re +from typing import List, Optional, Dict + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + State, + INextable, + StateMachine, + TaskInput, + Pass, + Choice, + Condition, + JsonPath, +) +from aws_cdk.core import Construct + +from personalize.scheduler.base import Scheduler + + +class SchedulerFragment(StateMachineFragment): + def __init__( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + schedule_for: str, + schedule_for_suffix: str, + scheduler: Scheduler, + target: StateMachine, + schedule_path: str, + schedule_input_path: Optional[str] = "", + schedule_input: Optional[Dict] = None, + ): + construct_id = " ".join(["Schedule", schedule_for]).strip() + super().__init__(scope, construct_id) + + if not schedule_input_path and not schedule_input: + raise ValueError( + "schedule_input_path or schedule_input must be provided, not both" + ) + schedule_input = schedule_input or schedule_input_path + + schedule_input_key = "input" + if schedule_input_path: + schedule_input_key += ".$" + + # set up the schedule name + schedule_for_task_name = re.sub(r"[^0-9A-Za-z-_]", "-", schedule_for)[:80] + schedule_for_task_name = ( + f"States.Format('{schedule_for_task_name}-{{}}', {schedule_for_suffix})" + ) + + self.not_required = Pass(self, f"{schedule_for.title()} Schedule Not Required") + self.create_schedule = scheduler.create_scheduled_task.state( + self, + f"Create Schedule For {schedule_for.title()}", + payload=TaskInput.from_object( + { + "name.$": schedule_for_task_name, + "schedule.$": schedule_path, + "state_machine": { + "arn": target.state_machine_arn, + schedule_input_key: schedule_input, + }, + } + ), + result_path=JsonPath.DISCARD, + ) + self.start = ( + Choice(self, f"Check if {schedule_for.title()} Schedule Required") + .when(Condition.is_present(schedule_path), self.create_schedule) + .otherwise(self.not_required) + ) + + @property + def start_state(self) -> State: + return self.start.start_state + + @property + def end_states(self) -> List[INextable]: + return [self.not_required, self.create_schedule] diff --git a/source/infrastructure/personalize/step_functions/schedules.py b/source/infrastructure/personalize/step_functions/schedules.py new file mode 100644 index 0000000..85ddb6b --- /dev/null +++ b/source/infrastructure/personalize/step_functions/schedules.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + +from dataclasses import dataclass + +from aws_cdk.aws_stepfunctions import StateMachineFragment + + +@dataclass +class Schedules: + dataset_import: StateMachineFragment diff --git a/source/infrastructure/personalize/step_functions/solution_fragment.py b/source/infrastructure/personalize/step_functions/solution_fragment.py new file mode 100644 index 0000000..285060a --- /dev/null +++ b/source/infrastructure/personalize/step_functions/solution_fragment.py @@ -0,0 +1,313 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from typing import List, Optional + +from aws_cdk.aws_stepfunctions import ( + StateMachineFragment, + State, + INextable, + Choice, + Pass, + Map, + Condition, + JsonPath, + Parallel, + StateMachine, +) +from aws_cdk.core import Construct, Duration + +from personalize.aws_lambda.functions import ( + CreateSolution, + CreateSolutionVersion, + CreateCampaign, + CreateBatchInferenceJob, +) +from personalize.scheduler import Scheduler +from personalize.step_functions.batch_inference_jobs_fragment import ( + BatchInferenceJobsFragment, +) +from personalize.step_functions.scheduler_fragment import SchedulerFragment + +TEMPORARY_PATH = "$._tmp" +BATCH_INFERENCE_JOB_PATH = "$.batchInferenceJob" +BUCKET_PATH = "$.bucket" +CURRENT_DATE_PATH = "$.currentDate" +MINIMUM_TIME = "1 second" + + +class SolutionFragment(StateMachineFragment): + def __init__( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + id: str, + create_solution: CreateSolution, + create_solution_version: CreateSolutionVersion, + create_campaign: CreateCampaign, + create_batch_inference_job: CreateBatchInferenceJob, + scheduler: Optional[Scheduler] = None, + to_schedule: Optional[StateMachine] = None, + ): + super().__init__(scope, id) + self.create_solution = create_solution + self.create_solution_version = create_solution_version + + # total allowed elapsed duration ~ 5h + retry_config = { + "backoff_rate": 1.02, + "interval": Duration.seconds(60), + "max_attempts": 100, + } + + # fmt: off + self.solutions_not_available = Pass(self, "Solutions not Provided") + self.solutions_available = Choice(self, "Check for Solutions").otherwise(self.solutions_not_available) + campaigns_available = Choice(self, "Check for Campaigns").otherwise(Pass(self, "Campaigns not Provided")) + + _prepare_solution_input = Pass( + self, + "Prepare Solution Input Data", + input_path="$.datasetGroupArn", # NOSONAR (python:S1192) - string for clarity + result_path="$.solution.serviceConfig.datasetGroupArn", + ) + + _prepare_solution_output = Pass( + self, + "Prepare Solution Output Data", + input_path=f"{TEMPORARY_PATH}.solutionArn", + result_path="$.solution.serviceConfig.solutionArn", + ) + + _prepare_solution_version_input = Pass( + self, + "Prepare Solution Version Input Data", + parameters={ + "serviceConfig": { + "solutionArn.$": "$.solution.serviceConfig.solutionArn", # NOSONAR (python:S1192) - string for clarity + "trainingMode": "FULL" + }, + "workflowConfig": { + "maxAge": "365 days" # do not create a new solution version on new file upload + } + }, + result_path = "$.solution.solutionVersion", # NOSONAR (python:S1192) - string for clarity + ) + + _prepare_solution_version_output = Pass( + self, + "Prepare Solution Version Output Data", + input_path=f"{TEMPORARY_PATH}.solutionVersionArn", + result_path="$.solution.solutionVersion.serviceConfig.solutionVersionArn", # NOSONAR (python:S1192) - string for clarity + ) + + _prepare_campaign_input = Pass( + self, + "Prepare Campaign Input Data", + input_path="$.solutionVersionArn", # NOSONAR (python:S1192) - string for clarity + result_path="$.campaign.serviceConfig.solutionVersionArn", + ) + + _create_solution = create_solution.state( + self, + "Create Solution", + result_path=TEMPORARY_PATH, + input_path="$.solution", + result_selector={ + "solutionArn.$": "$.solutionArn" + } + ) + + _create_solution_version = create_solution_version.state( + self, + "Create Solution Version", + result_path=TEMPORARY_PATH, + input_path="$.solution.solutionVersion", + result_selector={ + "solutionVersionArn.$": "$.solutionVersionArn" # NOSONAR (python:S1192) - string for clarity + }, + **retry_config, + ) + _create_solution_version.task.add_catch( + Pass( + self, + "Save Solution Version ID", + parameters={ + "errorInfo.$": "States.StringToJson($.solutionVersionPending.Cause)" + }, + result_path=TEMPORARY_PATH + ).next( + Pass( + self, + "Set Solution Version ID", + parameters={ + "serviceConfig": { + "trainingMode.$": "$.solution.solutionVersion.serviceConfig.trainingMode", + "solutionArn.$": "$.solution.solutionVersion.serviceConfig.solutionArn", # NOSONAR (python:S1192) - string for clarity + }, + "workflowConfig": { + "maxAge.$": "$.solution.solutionVersion.workflowConfig.maxAge", + "solutionVersionArn.$": f"{TEMPORARY_PATH}.errorInfo.errorMessage" + } + }, + result_path="$.solution.solutionVersion" + ) + ).next(_create_solution_version), + errors=["SolutionVersionPending"], + result_path="$.solutionVersionPending" + ) + + _create_campaign = create_campaign.state( + self, + "Create Campaign", + result_path="$.campaign.serviceConfig", + input_path="$.campaign", + **retry_config, + ) + + _create_batch_inference_jobs = BatchInferenceJobsFragment( + self, + "Create Batch Inference Jobs", + create_batch_inference_job=create_batch_inference_job, + scheduler=scheduler, + to_schedule=to_schedule, + ).start_state + + self.create_campaigns = campaigns_available.when( + Condition.is_present("$.solution.campaigns[0]"), + Map( + self, + "Create Campaigns", + items_path="$.solution.campaigns", # NOSONAR (python:S1192) - string for clarity + parameters={ + "solutionVersionArn.$": "$.solution.solutionVersion.serviceConfig.solutionVersionArn", + "campaign.$": "$$.Map.Item.Value", + } + ).iterator(_prepare_campaign_input + .next(_create_campaign)) + ) + + campaigns_and_batch = Parallel( + self, + "Create Campaigns and Batch Inference Jobs", + result_path=JsonPath.DISCARD + ) + campaigns_and_batch.branch(self.create_campaigns) + campaigns_and_batch.branch(_create_batch_inference_jobs) + if scheduler and to_schedule: + campaigns_and_batch.next( + SchedulerFragment( + self, + schedule_for="solution maintenance full", + schedule_for_suffix="$.solution.serviceConfig.name", # NOSONAR (python:S1192) - string for clarity + scheduler=scheduler, + target=to_schedule, + schedule_path="$.solution.workflowConfig.schedules.full", + schedule_input={ + "bucket.$": BUCKET_PATH, # NOSONAR (python:S1192) - string for clarity + "datasetGroup": { + "serviceConfig": { + "name.$": "$.datasetGroupName", + "datasetGroupArn.$": "$.datasetGroupArn", # NOSONAR (python:S1192) - string for clarity + } + }, + "solutions": [ + { + "serviceConfig.$": "$.solution.serviceConfig", + "solutionVersion": { + "serviceConfig": { + "trainingMode": "FULL", + "solutionArn.$": "$.solution.solutionVersion.serviceConfig.solutionArn", # NOSONAR (python:S1192) - string for clarity + }, + "workflowConfig": { + "maxAge": MINIMUM_TIME + } + }, + "campaigns.$": "$.solution.campaigns", + } + ] + } + ) + ).next( + SchedulerFragment( + self, + schedule_for="solution maintenance update", + schedule_for_suffix="$.solution.serviceConfig.name", # NOSONAR (python:S1192) - string for clarity + scheduler=scheduler, + target=to_schedule, + schedule_path="$.solution.workflowConfig.schedules.update", + schedule_input={ + "bucket.$": BUCKET_PATH, + "datasetGroup": { + "serviceConfig": { + "name.$": "$.datasetGroupName", + "datasetGroupArn.$": "$.datasetGroupArn", + } + }, + "solutions": [ + { + "serviceConfig.$": "$.solution.serviceConfig", + "solutionVersion": { + "serviceConfig": { + "trainingMode": "UPDATE", + "solutionArn.$": "$.solution.solutionVersion.serviceConfig.solutionArn", # NOSONAR (python:S1192) - string for clarity + }, + "workflowConfig": { + "maxAge": MINIMUM_TIME, + } + }, + "campaigns.$": "$.solution.campaigns", + } + ] + } + ) + ) + + _check_solution_version = Choice(self, "Check for Solution Version") + _check_solution_version.when( + Condition.is_not_present("$.solution.solutionVersion"), + _prepare_solution_version_input + ) + _check_solution_version.afterwards().next( + _create_solution_version + .next(_prepare_solution_version_output) + .next(campaigns_and_batch) + ) + _check_solution_version.otherwise(_create_solution_version) + + self._create_solutions = Map( + self, + "Create Solutions", + items_path="$.solutions", + result_path=JsonPath.DISCARD, + parameters={ + "datasetGroupArn.$": "$.datasetGroup.serviceConfig.datasetGroupArn", + "datasetGroupName.$": "$.datasetGroup.serviceConfig.name", + "solution.$": "$$.Map.Item.Value", + "bucket.$": BUCKET_PATH, + "currentDate.$": CURRENT_DATE_PATH, # NOSONAR (python:S1192) - string for clarity + } + ).iterator(_prepare_solution_input + .next(_create_solution) + .next(_prepare_solution_output) + .next(_check_solution_version) + ) + + self.solutions_available.when(Condition.is_present("$.solutions[0]"), self._create_solutions) + # fmt: on + + @property + def start_state(self) -> State: + return self.solutions_available + + @property + def end_states(self) -> List[INextable]: + return [self._create_solutions, self.solutions_not_available] diff --git a/source/infrastructure/setup.py b/source/infrastructure/setup.py new file mode 100644 index 0000000..8ed4d70 --- /dev/null +++ b/source/infrastructure/setup.py @@ -0,0 +1,49 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### + + +from pathlib import Path + +import setuptools + +readme_path = Path(__file__).resolve().parent.parent.parent / "README.md" +with open(readme_path) as fp: + long_description = fp.read() + + +setuptools.setup( + name="infrastructure", + version="1.0.0", + description="AWS CDK stack to deploy the AWS MLOps for Amazon Personalize solution.", + long_description=long_description, + long_description_content_type="text/markdown", + author="AWS Solutions Builders", + packages=setuptools.find_packages(), + install_requires=[ + "aws-cdk.core>=1.95.2", + ], + python_requires=">=3.6", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: JavaScript", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Topic :: Software Development :: Code Generators", + "Topic :: Utilities", + "Typing :: Typed", + ], +) diff --git a/source/pytest.ini b/source/pytest.ini new file mode 100644 index 0000000..70cdb14 --- /dev/null +++ b/source/pytest.ini @@ -0,0 +1,15 @@ +[pytest] +env = + MOTO_ACCOUNT_ID=111111111111 + POWERTOOLS_TRACE_DISABLED=1 + SOLUTION_ID=SO0170test + SOLUTION_VERSION=v99.99.99 + SOLUTION_NAME=Maintaining Personalized Experiences with Machine Learning + AWS_REGION=us-east-1 + AWS_DEFAULT_REGION=us-east-1 + DDB_SCHEDULES_TABLE=scheduler + POWERTOOLS_SERVICE_NAME=personalize_solution_teststack + POWERTOOLS_METRICS_NAMESPACE=personalize_solution_teststack +norecursedirs = cdk.out* +markers= + no_cdk_lambda_mock: marks test that need to build AWS Lambda Functions or Layers with CDK \ No newline at end of file diff --git a/source/requirements-dev.txt b/source/requirements-dev.txt new file mode 100644 index 0000000..e5fe524 --- /dev/null +++ b/source/requirements-dev.txt @@ -0,0 +1,21 @@ +avro==1.10.2 +black +boto3 +aws_cdk.core>=1.120.0 +aws_cdk.aws_stepfunctions_tasks>=1.120.0 +aws_solutions_constructs.aws_lambda_sns>=1.120.0 +requests==2.24.0 +crhelper==2.0.6 +cronex==0.1.3.1 +moto==2.0.8 +parsedatetime==2.6 +pytest +pytest-cov>=2.11.1 +pytest-env>=0.6.2 +pytest-mock>=3.5.1 +tenacity>=8.0.1 +-e cdk_solution_helper_py/helpers_cdk +-e cdk_solution_helper_py/helpers_common +aws-lambda-powertools==1.15.0 +docker==5.0.0 +-e infrastructure \ No newline at end of file diff --git a/source/tests/__init__.py b/source/tests/__init__.py new file mode 100644 index 0000000..ef2f9eb --- /dev/null +++ b/source/tests/__init__.py @@ -0,0 +1,12 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### diff --git a/source/tests/aws_lambda/create_batch_inference_job/test_batch_inference_job_handler.py b/source/tests/aws_lambda/create_batch_inference_job/test_batch_inference_job_handler.py new file mode 100644 index 0000000..f587496 --- /dev/null +++ b/source/tests/aws_lambda/create_batch_inference_job/test_batch_inference_job_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_batch_inference_job.handler import lambda_handler + + +def test_create_batch_inference_job_handler(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_campaign/test_create_campaign_handler.py b/source/tests/aws_lambda/create_campaign/test_create_campaign_handler.py new file mode 100644 index 0000000..3b6b33c --- /dev/null +++ b/source/tests/aws_lambda/create_campaign/test_create_campaign_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_campaign.handler import lambda_handler + + +def test_create_campaign(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_dataset/test_dataset_handler.py b/source/tests/aws_lambda/create_dataset/test_dataset_handler.py new file mode 100644 index 0000000..9b6b2aa --- /dev/null +++ b/source/tests/aws_lambda/create_dataset/test_dataset_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_dataset.handler import lambda_handler + + +def test_create_dataset_handler(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_dataset_group/test_dataset_group_handler.py b/source/tests/aws_lambda/create_dataset_group/test_dataset_group_handler.py new file mode 100644 index 0000000..21abaf3 --- /dev/null +++ b/source/tests/aws_lambda/create_dataset_group/test_dataset_group_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_dataset_group.handler import lambda_handler + + +def test_handler(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_dataset_import_job/test_dataset_import_job_handler.py b/source/tests/aws_lambda/create_dataset_import_job/test_dataset_import_job_handler.py new file mode 100644 index 0000000..6e82eb4 --- /dev/null +++ b/source/tests/aws_lambda/create_dataset_import_job/test_dataset_import_job_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_dataset_import_job.handler import lambda_handler + + +def test_create_dataset_import_job_handler(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_event_tracker/test_create_event_tracker_handler.py b/source/tests/aws_lambda/create_event_tracker/test_create_event_tracker_handler.py new file mode 100644 index 0000000..4c8f7ac --- /dev/null +++ b/source/tests/aws_lambda/create_event_tracker/test_create_event_tracker_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_event_tracker.handler import lambda_handler + + +def test_create_event_tracker(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_filter/test_create_filter_handler.py b/source/tests/aws_lambda/create_filter/test_create_filter_handler.py new file mode 100644 index 0000000..a5c093d --- /dev/null +++ b/source/tests/aws_lambda/create_filter/test_create_filter_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_filter.handler import lambda_handler + + +def test_create_filter(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_schema/create_schema_handler.py b/source/tests/aws_lambda/create_schema/create_schema_handler.py new file mode 100644 index 0000000..a1b957c --- /dev/null +++ b/source/tests/aws_lambda/create_schema/create_schema_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_schema.handler import lambda_handler + + +def test_create_schema_handler(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_solution/test_create_solution_handler.py b/source/tests/aws_lambda/create_solution/test_create_solution_handler.py new file mode 100644 index 0000000..4d27ff1 --- /dev/null +++ b/source/tests/aws_lambda/create_solution/test_create_solution_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_solution.handler import lambda_handler + + +def test_create_solution(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/create_solution_version/test_create_solution_version_handler.py b/source/tests/aws_lambda/create_solution_version/test_create_solution_version_handler.py new file mode 100644 index 0000000..b9a1d7a --- /dev/null +++ b/source/tests/aws_lambda/create_solution_version/test_create_solution_version_handler.py @@ -0,0 +1,21 @@ +# ###################################################################################################################### +# 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 pytest + +from aws_lambda.create_solution_version.handler import lambda_handler + + +def test_create_solution_version_handler(): + with pytest.raises(ValueError): + lambda_handler({}, None) diff --git a/source/tests/aws_lambda/s3_event/test_s3_event_handler.py b/source/tests/aws_lambda/s3_event/test_s3_event_handler.py new file mode 100644 index 0000000..4c3387b --- /dev/null +++ b/source/tests/aws_lambda/s3_event/test_s3_event_handler.py @@ -0,0 +1,183 @@ +# ###################################################################################################################### +# 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 json +from os import environ + +import boto3 +import pytest +from moto import mock_s3, mock_stepfunctions, mock_sns, mock_sts + +from aws_lambda.s3_event.handler import lambda_handler +from aws_solutions.core.helpers import _helpers_service_clients + + +@pytest.fixture +def s3_event(): + return { + "Records": [ + { + "eventVersion": "2.2", + "eventSource": "aws:s3", + "awsRegion": "us-west-2", + "eventTime": "The time, in ISO-8601 format, for example, 1970-01-01T00:00:00.000Z, when Amazon S3 finished processing the request", + "eventName": "event-type", + "userIdentity": { + "principalId": "Amazon-customer-ID-of-the-user-who-caused-the-event" + }, + "requestParameters": { + "sourceIPAddress": "ip-address-where-request-came-from" + }, + "responseElements": { + "x-amz-request-id": "Amazon S3 generated request ID", + "x-amz-id-2": "Amazon S3 host that processed the request", + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "ID found in the bucket notification configuration", + "bucket": { + "name": "bucket-name", + "ownerIdentity": { + "principalId": "Amazon-customer-ID-of-the-bucket-owner" + }, + "arn": "bucket-ARN", + }, + "object": { + "key": "train/object-key.json", + "size": "object-size", + "eTag": "object eTag", + "versionId": "object version if bucket is versioning-enabled, otherwise null", + "sequencer": "a string representation of a hexadecimal value used to determine event sequence, only used with PUTs and DELETEs", + }, + }, + "glacierEventData": { + "restoreEventData": { + "lifecycleRestorationExpiryTime": "The time, in ISO-8601 format, for example, 1970-01-01T00:00:00.000Z, of Restore Expiry", + "lifecycleRestoreStorageClass": "Source storage class for restore", + } + }, + } + ] + } + + +@pytest.fixture +def simple_definition(): + definition = { + "StartAt": "FirstState", + "States": { + "Type": "Task", + "Resource": f"arn:aws:lambda:us-east-1:{'1'*12}:function:FUNCTION_NAME", + "End": True, + }, + } + return json.dumps(definition) + + +@pytest.fixture +def stepfunctions_mocked(simple_definition): + with mock_stepfunctions(): + client = boto3.client("stepfunctions") + client.create_state_machine( + name="personalize-workflow", + definition=simple_definition, + roleArn=f"arn:aws:iam::{'1' * 12}:role/sf_role", + ) + _helpers_service_clients["stepfunctions"] = client + yield client + + +@pytest.fixture +def s3_mocked(s3_event, configuration_path): + with mock_s3(): + client = boto3.client("s3") + client.create_bucket(Bucket="bucket-name") + client.put_object( + Bucket="bucket-name", + Key="train/object-key.json", + Body=configuration_path.read_text(), + ) + _helpers_service_clients["s3"] = client + yield client + + +@pytest.fixture +def sns_mocked(): + with mock_sns(): + client = boto3.client("sns") + client.create_topic( + Name="some-personalize-notification-topic", + ) + _helpers_service_clients["sns"] = client + yield client + + +@mock_sts +def test_s3_event_handler(s3_event, sns_mocked, s3_mocked, stepfunctions_mocked): + lambda_handler(s3_event, None) + + # ensure that execution has started + executions = stepfunctions_mocked.list_executions( + stateMachineArn=environ.get("STATE_MACHINE_ARN"), + ) + assert len(executions["executions"]) == 1 + assert executions["executions"][0]["status"] == "RUNNING" + + +@mock_sts +def test_s3_event_handler_working( + s3_event, sns_mocked, s3_mocked, stepfunctions_mocked +): + s3_mocked.put_object( + Bucket="bucket-name", + Key="train/object-key.json", + Body=json.dumps({"datasetGroup": {"serviceConfig": {"name": "testDsg"}}}), + ) + lambda_handler(s3_event, None) + + # ensure no executions started + executions = stepfunctions_mocked.list_executions( + stateMachineArn=environ.get("STATE_MACHINE_ARN"), + ) + assert len(executions["executions"]) == 1 + + +@mock_sts +def test_s3_event_handler_bad_json( + s3_event, sns_mocked, s3_mocked, stepfunctions_mocked +): + s3_mocked.put_object(Bucket="bucket-name", Key="train/object-key.json", Body="{") + lambda_handler(s3_event, None) + + # ensure no executions started + executions = stepfunctions_mocked.list_executions( + stateMachineArn=environ.get("STATE_MACHINE_ARN"), + ) + assert len(executions["executions"]) == 0 + + +@mock_sts +def test_s3_event_handler_bad_config( + s3_event, sns_mocked, s3_mocked, stepfunctions_mocked +): + s3_mocked.put_object( + Bucket="bucket-name", + Key="train/object-key.json", + Body='{"this": "is not configuration data"}', + ) + lambda_handler(s3_event, None) + + # ensure no executions started + executions = stepfunctions_mocked.list_executions( + stateMachineArn=environ.get("STATE_MACHINE_ARN"), + ) + assert len(executions["executions"]) == 0 diff --git a/source/tests/aws_lambda/sns_notification/test_sns_notification.py b/source/tests/aws_lambda/sns_notification/test_sns_notification.py new file mode 100644 index 0000000..a20be01 --- /dev/null +++ b/source/tests/aws_lambda/sns_notification/test_sns_notification.py @@ -0,0 +1,108 @@ +# ###################################################################################################################### +# 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 os +from collections import namedtuple + +import pytest +from botocore.stub import Stubber + +from aws_lambda.sns_notification.handler import lambda_handler +from aws_solutions.core import get_service_client + +TRACE_ID = "1-57f5498f-d91047849216d0f2ea3b6442" + + +@pytest.fixture +def sns_stubber(): + sns_client = get_service_client("sns") + with Stubber(sns_client) as stubber: + yield stubber + + +@pytest.fixture +def trace_enabled(): + os.environ["_X_AMZN_TRACE_ID"] = TRACE_ID + yield + del os.environ["_X_AMZN_TRACE_ID"] + + +DATASET_GROUP_NAME = "DATASET_GROUP_NAME" +EXPECTED_MESSAGE = """ +There was an error running the personalization job for dataset group DATASET_GROUP_NAME + +Message: ERROR_MESSAGE + +""".lstrip( + "\n" +) +EXPECTED_MESSAGE_TRACE = f""" +There was an error running the personalization job for dataset group DATASET_GROUP_NAME + +Message: ERROR_MESSAGE + +Traces: https://console.aws.amazon.com/xray/home?region=us-east-1#/traces/{TRACE_ID} +""".strip( + "\n" +) + + +@pytest.fixture +def context(): + ctx = namedtuple("Context", ["invoked_function_arn"]) + return ctx(f"arn:aws:lambda:us-east-1:{'1' * 12}:function:my-function:1") + + +def test_sns_notification(sns_stubber, context): + """Test without traces""" + sns_stubber.add_response( + "publish", + {}, + expected_params={ + "TopicArn": os.environ.get("SNS_TOPIC_ARN"), + "Subject": "Maintaining Personalized Experiences with Machine Learning Notifications", + "Message": EXPECTED_MESSAGE, + }, + ) + + lambda_handler( + { + "datasetGroup": DATASET_GROUP_NAME, + "statesError": { + "Cause": '{"errorMessage": "ERROR_MESSAGE"}', + }, + }, + context, + ) + + +def test_sns_notification_trace(sns_stubber, trace_enabled, context): + """Test with traces""" + sns_stubber.add_response( + "publish", + {}, + expected_params={ + "TopicArn": os.environ.get("SNS_TOPIC_ARN"), + "Subject": "Maintaining Personalized Experiences with Machine Learning Notifications", + "Message": EXPECTED_MESSAGE_TRACE, + }, + ) + + lambda_handler( + { + "datasetGroup": DATASET_GROUP_NAME, + "statesError": { + "Cause": '{"errorMessage": "ERROR_MESSAGE"}', + }, + }, + context, + ) diff --git a/source/tests/aws_lambda/test_personalize_service.py b/source/tests/aws_lambda/test_personalize_service.py new file mode 100644 index 0000000..a4cf79f --- /dev/null +++ b/source/tests/aws_lambda/test_personalize_service.py @@ -0,0 +1,492 @@ +# ###################################################################################################################### +# 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 binascii +import json +import os +from datetime import datetime + +import boto3 +import pytest +from dateutil import tz +from dateutil.tz import tzlocal +from moto import mock_s3, mock_sts + +from aws_lambda.shared.personalize_service import ( + S3, + Personalize, + ServiceModel, + Configuration, + get_duplicates, +) +from shared.exceptions import ResourceNeedsUpdate, ResourceFailed +from shared.resource import Campaign + + +@pytest.fixture +def config_empty(tmp_path): + f = tmp_path / "config.json" + f.write_text("{}") + yield f + + +@pytest.fixture +def mocked_s3(): + with mock_s3(): + cli = boto3.client("s3") + cli.create_bucket(Bucket="test") + cli.put_object(Bucket="test", Key="test.csv", Body="some_body") + cli.put_object(Bucket="test", Key="sub/test.csv", Body="some_body") + cli.put_object(Bucket="test", Key="sub/sub/test.csv", Body="some_body") + cli.put_object(Bucket="test", Key="sub1/", Body="") + yield boto3.resource("s3") + + +@pytest.fixture +def describe_solution_version_response(): + return { + "solutionVersion": { + "solutionVersionArn": f'arn:aws:personalize:us-east-1:{"1" * 12}:solution/personalize-integration-test-ranking/dfcd6f6e', + "solutionArn": f'arn:aws:personalize:us-east-1:{"1" * 12}:solution/personalize-integration-test-ranking', + "performHPO": False, + "performAutoML": False, + "recipeArn": "arn:aws:personalize:::recipe/aws-user-personalization", + "datasetGroupArn": f'arn:aws:personalize:us-east-1:{"1" * 12}:dataset-group/personalize-integration-test', + "solutionConfig": {}, + "trainingHours": 1.546, + "trainingMode": "FULL", + "status": "ACTIVE", + "creationDateTime": datetime( + 2021, 9, 2, 14, 54, 56, 406000, tzinfo=tzlocal() + ), + "lastUpdatedDateTime": datetime( + 2021, 9, 2, 15, 16, 23, 424000, tzinfo=tzlocal() + ), + }, + "ResponseMetadata": {}, + } + + +@pytest.mark.parametrize( + "url,bucket,key", + ( + ["s3://test/test.csv", "test", "test.csv"], + ["s3://test/sub/test.csv", "test", "sub/test.csv"], + ["s3://test/sub/sub/test.csv", "test", "sub/sub/test.csv"], + ), +) +def test_s3_urlparse(mocked_s3, url, bucket, key): + s3 = S3(url) + assert s3.url == url + assert s3.bucket == bucket + assert s3.key == key + + +def test_s3_exists_csv(mocked_s3): + s3 = S3("s3://test/sub/test.csv") + s3.cli = mocked_s3 + + assert s3.exists + assert s3.last_modified + + +def test_s3_exists_path(mocked_s3): + s3 = S3("s3://test/sub") + s3.cli = mocked_s3 + + assert s3.exists + assert s3.last_modified + + +def test_no_such_key_csv(mocked_s3): + s3 = S3("s3://test/DOES_NOT_EXIST.csv") + s3.cli = mocked_s3 + + assert not s3.exists + assert not s3.last_modified + + +def test_no_such_key_path(mocked_s3): + s3 = S3("s3://test/DOES_NOT_EXIST") + s3.cli = mocked_s3 + + assert not s3.exists + assert not s3.last_modified + + +@mock_sts +def test_service_model(personalize_stubber): + cli = Personalize() + + # set up a service response for the depth-first listing structure expected + dataset_group_name_1 = "dsg1" + dataset_group_name_2 = "dsg2" + solution_name_1 = "sol1" + solution_name_2 = "sol2" + filter_name_1 = "filter1" + filter_name_2 = "filter2" + campaign_name_1 = "campaign1" + campaign_name_2 = "campaign2" + dataset_group_arn_1 = ( + f"arn:aws:personalize:us-east-1:{'1' * 12}:dataset-group/{dataset_group_name_1}" + ) + dataset_group_arn_2 = ( + f"arn:aws:personalize:us-east-1:{'1' * 12}:dataset-group/{dataset_group_name_2}" + ) + dataset_arn_1 = f"arn:aws:personalize:us-east-1:{'1' * 12}:dataset/{dataset_group_name_1}/INTERACTIONS" + dataset_arn_2 = f"arn:aws:personalize:us-east-1:{'1' * 12}:dataset/{dataset_group_name_2}/INTERACTIONS" + solution_arn_1 = ( + f"arn:aws:personalize:us-east-1:{'1' * 12}:solution/{solution_name_1}" + ) + solution_arn_2 = ( + f"arn:aws:personalize:us-east-1:{'1' * 12}:solution/{solution_name_2}" + ) + filter_arn_1 = f"arn:aws:personalize:us-east-1:{'1' * 12}:filter/{filter_name_1}" + filter_arn_2 = f"arn:aws:personalize:us-east-1:{'1' * 12}:filter/{filter_name_2}" + campaign_arn_1 = ( + f"arn:aws:personalize:us-east-1:{'1' * 12}:filter/{campaign_name_1}" + ) + campaign_arn_2 = ( + f"arn:aws:personalize:us-east-1:{'1' * 12}:filter/{campaign_name_2}" + ) + + # all dataset groups + personalize_stubber.add_response( + method="list_dataset_groups", + service_response={ + "datasetGroups": [ + {"datasetGroupArn": dataset_group_arn_1}, + {"datasetGroupArn": dataset_group_arn_2}, + ] + }, + ) + + # first dataset group + personalize_stubber.add_response( + method="list_datasets", + expected_params={"datasetGroupArn": dataset_group_arn_1}, + service_response={"datasets": [{"datasetArn": dataset_arn_1}]}, + ) + personalize_stubber.add_response( + method="list_dataset_import_jobs", + expected_params={"datasetArn": dataset_arn_1}, + service_response={"datasetImportJobs": []}, + ) + personalize_stubber.add_response( + method="list_filters", + expected_params={"datasetGroupArn": dataset_group_arn_1}, + service_response={"Filters": [{"filterArn": filter_arn_1}]}, + ) + personalize_stubber.add_response( + method="list_solutions", + expected_params={"datasetGroupArn": dataset_group_arn_1}, + service_response={"solutions": [{"solutionArn": solution_arn_1}]}, + ) + personalize_stubber.add_response( + method="list_campaigns", + expected_params={"solutionArn": solution_arn_1}, + service_response={"campaigns": [{"campaignArn": campaign_arn_1}]}, + ) + personalize_stubber.add_response( + method="list_solution_versions", + expected_params={"solutionArn": solution_arn_1}, + service_response={"solutionVersions": []}, + ) + + # second dataset group + personalize_stubber.add_response( + method="list_datasets", + expected_params={"datasetGroupArn": dataset_group_arn_2}, + service_response={"datasets": [{"datasetArn": dataset_arn_2}]}, + ) + personalize_stubber.add_response( + method="list_dataset_import_jobs", + expected_params={"datasetArn": dataset_arn_2}, + service_response={"datasetImportJobs": []}, + ) + personalize_stubber.add_response( + method="list_filters", + expected_params={"datasetGroupArn": dataset_group_arn_2}, + service_response={"Filters": [{"filterArn": filter_arn_2}]}, + ) + personalize_stubber.add_response( + method="list_solutions", + expected_params={"datasetGroupArn": dataset_group_arn_2}, + service_response={"solutions": [{"solutionArn": solution_arn_2}]}, + ) + personalize_stubber.add_response( + method="list_campaigns", + expected_params={"solutionArn": solution_arn_2}, + service_response={"campaigns": [{"campaignArn": campaign_arn_2}]}, + ) + personalize_stubber.add_response( + method="list_solution_versions", + expected_params={"solutionArn": solution_arn_2}, + service_response={"solutionVersions": []}, + ) + + sm = ServiceModel(cli) + + assert sm.owned_by(filter_arn_1, dataset_group_arn_1) + assert sm.owned_by(campaign_arn_1, dataset_group_name_1) + assert sm.owned_by(filter_arn_2, dataset_group_arn_2) + assert sm.owned_by(campaign_arn_2, dataset_group_name_2) + for arn in [ + dataset_group_arn_1, + dataset_group_arn_2, + campaign_arn_1, + campaign_arn_2, + filter_arn_1, + filter_arn_2, + solution_arn_1, + solution_arn_2, + ]: + assert not sm.available(arn) + + +@mock_sts +def test_configuration_valid(configuration_path): + cfg = Configuration() + cfg.load(configuration_path) + validates = cfg.validate() + assert validates + + +@mock_sts +def test_configuration_empty(config_empty): + cfg = Configuration() + cfg.load(config_empty) + validates = cfg.validate() + assert not validates + + +def test_get_duplicates_str(): + assert get_duplicates("hello") == [] + + +def test_get_duplicates_list(): + assert get_duplicates([1, 2, 3]) == [] + + +def test_get_duplicates_list_dup(): + assert get_duplicates([1, 1, 1, 2]) == [1] + + +def test_personalize_service_check_solution(): + personalize = Personalize() + with pytest.raises(ResourceFailed): + personalize._check_solution( + "arn:aws:personalize:us-east-1:aaaaaaaaaaaa:solution/unit_test_solution_1/e970b8a3", + "arn:aws:personalize:us-east-1:aaaaaaaaaaaa:solution/unit_test_solution_2/b944e180", + ) + + +def test_describe_with_update(mocker): + personalize = Personalize() + + arn = "arn:aws:personalize:us-east-1:awsaccountid:solution/unit_test_solution_1/aaaaaaaa" + + describe_mock = mocker.MagicMock() + describe_mock.return_value = { + "campaign": { + "solutionVersionArn": arn, + } + } + personalize.describe_default = describe_mock + + assert ( + personalize.describe_with_update(resource=Campaign(), solutionVersionArn=arn) + == describe_mock.return_value + ) + + with pytest.raises(ResourceNeedsUpdate): + personalize.describe_with_update( + resource=Campaign(), solutionVersionArn=arn.replace("aaaaaaaa", "bbbbbbbb") + ) + + +@pytest.mark.parametrize( + "old_job,new_job,expected", + [ + ({"status": "ACTIVE"}, None, True), + ({"status": "CREATE PENDING"}, None, True), + ({"status": "FAILED"}, None, False), + ], +) +def test_is_current(old_job, new_job, expected): + cli = Personalize() + + if not new_job: + new_job = old_job + + assert cli.is_current(old_job, new_job) is expected + + +@mock_sts +def test_new_resource_solution_version(personalize_stubber): + """describing a solution version with a maxAge and an ARN should resolve""" + cli = Personalize() + + solution_name = "solution_name" + solution_arn = f"arn:aws:personalize:us-east-1:{'1' * 12}:solution/{solution_name}" + + def solution_version_arn(): + return f"arn:aws:personalize:us-east-1:{'1' * 12}:solution/{solution_name}/{binascii.b2a_hex(os.urandom(4)).decode('utf-8')}" + + sv_arn_old = solution_version_arn() + sv_arn_new = solution_version_arn() + + personalize_stubber.add_response( + method="list_solution_versions", + expected_params={"solutionArn": solution_arn}, + service_response={ + "solutionVersions": [ + { + "creationDateTime": datetime( + 1999, 1, 1, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "lastUpdatedDateTime": datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "status": "ACTIVE", + "solutionVersionArn": sv_arn_old, + }, + { + "creationDateTime": datetime( + 1999, 1, 2, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "lastUpdatedDateTime": datetime( + 2000, 1, 2, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "status": "ACTIVE", + "solutionVersionArn": sv_arn_new, + }, + ] + }, + ) + personalize_stubber.add_response( + method="describe_solution_version", + service_response={ + "solutionVersion": { + "solutionVersionArn": sv_arn_new, + "solutionArn": solution_arn, + } + }, + ) + + result = cli.describe_solution_version( + trainingMode="FULL", + solutionArn=solution_arn, + maxAge=1, + solutionVersionArn=sv_arn_new, + ) + assert result["solutionVersion"]["solutionVersionArn"] == sv_arn_new + + +@mock_sts +def test_new_resource_solution_version(personalize_stubber): + """describing a solution version with a maxAge and no ARN should result in not found""" + cli = Personalize() + + solution_name = "solution_name" + solution_arn = f"arn:aws:personalize:us-east-1:{'1' * 12}:solution/{solution_name}" + + def solution_version_arn(): + return f"arn:aws:personalize:us-east-1:{'1' * 12}:solution/{solution_name}/{binascii.b2a_hex(os.urandom(4)).decode('utf-8')}" + + sv_arn_old = solution_version_arn() + sv_arn_new = solution_version_arn() + + personalize_stubber.add_response( + method="list_solution_versions", + expected_params={"solutionArn": solution_arn}, + service_response={ + "solutionVersions": [ + { + "creationDateTime": datetime( + 1999, 1, 1, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "lastUpdatedDateTime": datetime( + 2000, 1, 1, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "status": "ACTIVE", + "solutionVersionArn": sv_arn_old, + }, + { + "creationDateTime": datetime( + 1999, 1, 2, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "lastUpdatedDateTime": datetime( + 2000, 1, 2, 0, 0, 0, tzinfo=tz.tzlocal() + ), + "status": "ACTIVE", + "solutionVersionArn": sv_arn_new, + }, + ] + }, + ) + personalize_stubber.add_response( + method="describe_solution_version", + service_response={ + "solutionVersion": { + "solutionVersionArn": sv_arn_new, + "solutionArn": solution_arn, + } + }, + ) + + with pytest.raises(cli.exceptions.ResourceNotFoundException): + cli.describe_solution_version( + trainingMode="FULL", + solutionArn=solution_arn, + maxAge=1, + ) + + +def test_record_offline_metrics( + personalize_stubber, capsys, describe_solution_version_response +): + personalize = Personalize() + personalize_stubber.add_response( + method="get_solution_metrics", + service_response={ + "solutionVersionArn": f'arn:aws:personalize:us-east-1:{"1"*12}:solution/personalize-integration-test-ranking/dfcd6f6e', + "metrics": { + "coverage": 0.3235, + "mean_reciprocal_rank_at_25": 0.3274, + "normalized_discounted_cumulative_gain_at_10": 0.332, + "normalized_discounted_cumulative_gain_at_25": 0.4746, + "normalized_discounted_cumulative_gain_at_5": 0.2338, + "precision_at_10": 0.15, + "precision_at_25": 0.13, + "precision_at_5": 0.15, + }, + "ResponseMetadata": {}, + }, + ) + personalize._record_offline_metrics( + solution_version=describe_solution_version_response + ) + + log = capsys.readouterr().out.strip() + metrics = json.loads(log) + + assert metrics["service"] == "SolutionMetrics" + assert metrics["solutionArn"] + assert metrics["coverage"] + assert metrics["mean_reciprocal_rank_at_25"] + assert metrics["normalized_discounted_cumulative_gain_at_5"] + assert metrics["normalized_discounted_cumulative_gain_at_10"] + assert metrics["normalized_discounted_cumulative_gain_at_25"] + assert metrics["precision_at_5"] + assert metrics["precision_at_10"] + assert metrics["precision_at_25"] diff --git a/source/tests/aws_lambda/test_sfn_middleware.py b/source/tests/aws_lambda/test_sfn_middleware.py new file mode 100644 index 0000000..c6b2892 --- /dev/null +++ b/source/tests/aws_lambda/test_sfn_middleware.py @@ -0,0 +1,206 @@ +# ###################################################################################################################### +# 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 datetime +import logging +from decimal import Decimal + +import pytest +from moto import mock_sts + +from aws_lambda.shared.sfn_middleware import ( + PersonalizeResource, + STATUS_IN_PROGRESS, + STATUS_FAILED, + ResourcePending, + ResourceFailed, + ResourceInvalid, + json_handler, + set_defaults, + set_bucket, + parse_datetime, + Parameter, +) + + +@pytest.fixture +def personalize_resource(): + return PersonalizeResource( + resource="datasetGroup", + status="datasetGroup.status", + config={"name": {"source": "event", "path": "name"}}, + ) + + +def test_personalize_resource_status_active(personalize_resource): + assert personalize_resource.check_status({"datasetGroup": {"status": "ACTIVE"}}) + + +@pytest.mark.parametrize("status", STATUS_IN_PROGRESS) +def test_personalize_resource_status_pending(status, personalize_resource): + with pytest.raises(ResourcePending): + personalize_resource.check_status({"datasetGroup": {"status": status}}) + + +def test_personalize_resource_status_failed(personalize_resource): + status = STATUS_FAILED + + with pytest.raises(ResourceFailed): + personalize_resource.check_status({"datasetGroup": {"status": status}}) + + +def test_personalize_status_invalid(personalize_resource): + with pytest.raises(ResourceInvalid): + personalize_resource.check_status({"datasetGroup": {}}) + + +@mock_sts +def test_personalize_resource_decorator(personalize_resource, personalize_stubber): + """ + The typical workflow is to describe, then create, then raise ResourcePending + """ + dsg_name = "dsgName" + personalize_stubber.add_client_error( + "describe_dataset_group", "ResourceNotFoundException" + ) + personalize_stubber.add_response( + "create_dataset_group", {}, expected_params={"name": dsg_name} + ) + + @personalize_resource + def decorated(event, context): + pass # NOSONAR (python:S1186) - this is used for mocking + + with pytest.raises(ResourcePending): + decorated({"name": dsg_name}, None) + + +@pytest.mark.parametrize( + "item,serialized", + [ + (datetime.datetime(2020, 1, 1), "2020-01-01T00:00:00"), + (Decimal(1), 1), + (Decimal(1.5), 1.5), + ], + ids=[ + "datetime", + "Decimal integer", + "Decimal floating point", + ], +) +def test_json_handler(item, serialized): + assert json_handler(item) == serialized + + +def test_set_defaults_1(): + defaults = set_defaults({}) + del defaults["currentDate"] + del defaults["datasetGroup"] + assert defaults == {"solutions": []} + + +def test_set_defaults_2(): + defaults = set_defaults({"solutions": [{}]}) + del defaults["currentDate"] + del defaults["datasetGroup"] + + assert defaults == { + "solutions": [ + {"solutionVersions": [], "campaigns": [], "batchInferenceJobs": []} + ], + } + + +def test_set_defaults_3(): + defaults = set_defaults({}) + assert ( + defaults.get("datasetGroup").get("workflowConfig").get("maxAge") == "365 days" + ) + + +def test_set_defaults_4(): + defaults = set_defaults( + {"datasetGroup": {"workflowConfig": {"maxAge": "1 second"}}} + ) + assert defaults["datasetGroup"]["workflowConfig"]["maxAge"] == "1 second" + + +@pytest.mark.parametrize( + "bucket,key,expected", + [ + ("bucket-name", "train/bucket-key.json", "train"), + ("bucket-name", "train/sub1/bucket-key.json", "train/sub1"), + ("bucket-name", "train/sub1/sub2/bucket-key.json", "train/sub1/sub2"), + ], +) +def test_set_bucket(bucket, key, expected): + config = {} + result = set_bucket(config, bucket, key) + + assert result["bucket"]["name"] == bucket + assert result["bucket"]["key"] == expected + + +@pytest.mark.parametrize( + "time_string,seconds", + [ + ("1 day", 86400), + ("two days", 86400 * 2), + ("0.5 days", 86400 / 2), + ("1 week", 86400 * 7), + ("1 month", 86400 * 31), # there were 31 days in January 1 CE + ( + "1 year", + 86400 * 365, + ), # going higher than 3 years will result in off-by-one-day errors + ], +) +def test_parse_datetime(time_string, seconds, caplog): + with caplog.at_level(logging.WARNING): + assert parse_datetime(time_string) == seconds + if "month" in time_string or "year" in time_string: + assert ( + "they are based off of the calendar of the start of year 1 CE" + in caplog.text + ) + + +@pytest.mark.parametrize( + "key,source,path,format_as,default,result", + [ + ("key_a", "event", "key_a", None, None, "value_a"), + ("key_b", "environment", "SOLUTION_VERSION", None, None, "v99.99.99"), + ("key_c", "event", "key_c", "string", None, '{"some": "json"}'), + ("key_d", "event", "key_d", "seconds", None, 5), + ("key_e", "event", "key_e", None, "value_e", "value_e"), + ("key_f", "event", "key_f", "seconds", "one week", 604800), + ("key_g", "event", "key_g", None, "omit", None), + ], +) +def test_parameter_resolution(key, source, path, format_as, default, result): + event = { + "key_a": "value_a", + "key_c": {"some": "json"}, + "key_d": "five seconds", + "key_g": "", + } + + assert ( + Parameter( + key=key, + source=source, + path=path, + format_as=format_as, + default=default, + ).resolve(event) + == result + ) diff --git a/source/tests/cdk_solution_helper/__init__.py b/source/tests/cdk_solution_helper/__init__.py new file mode 100644 index 0000000..330623f --- /dev/null +++ b/source/tests/cdk_solution_helper/__init__.py @@ -0,0 +1,12 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### diff --git a/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name.py b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name.py new file mode 100644 index 0000000..e1c96b5 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name.py @@ -0,0 +1,74 @@ +# ##################################################################################################################### +# 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 re + +import pytest + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name.src.custom_resources.name import ( + generate_name, + get_property, + helper, +) + + +@pytest.fixture() +def lambda_event(): + event = { + "ResourceProperties": { + "Id": "UniqueId", + "StackName": "StackName", + "Purpose": "Purpose", + "MaxLength": 63, + } + } + yield event + + +def test_generate_name(lambda_event): + generate_name(lambda_event, None) + assert helper.Data["Name"] == "stackname-purpose-uniqueid" + + +def test_generate_long_name(lambda_event): + lambda_event["ResourceProperties"]["StackName"] = "a" * 63 + generate_name(lambda_event, None) + assert helper.Data["Name"] == "purpose-uniqueid" + + +def test_generate_invalid_name(lambda_event): + lambda_event["ResourceProperties"]["Purpose"] = "a" * 630 + with pytest.raises(ValueError): + generate_name(lambda_event, None) + + +def test_generate_name_random_id(lambda_event): + del lambda_event["ResourceProperties"]["Id"] + generate_name(lambda_event, None) + helper_id = helper.Data["Id"] + assert len(helper_id) == 12 + assert re.match(r"[a-f0-9]{12}", helper_id) + + +def test_get_property_present(lambda_event): + assert get_property(lambda_event, "StackName") == "StackName" + + +def test_get_property_default(lambda_event): + assert get_property(lambda_event, "MissingProperty", "DEFAULT") == "DEFAULT" + + +def test_get_property_missing(lambda_event): + with pytest.raises(ValueError): + get_property(lambda_event, "MissingProperty") diff --git a/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name_cdk.py b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name_cdk.py new file mode 100644 index 0000000..691ada7 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_hash/test_resource_name_cdk.py @@ -0,0 +1,44 @@ +# ##################################################################################################################### +# 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 pytest +from aws_cdk.core import Stack, App +from constructs import Construct + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name.name import ( + ResourceName, +) + + +class SomeStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + self.name_1 = ResourceName(self, "name_1", purpose="var_1", max_length=32) + self.name_2 = ResourceName(self, "name_2", purpose="var_2", max_length=32) + + +@pytest.fixture +def resource_naming_stack(): + app = App() + SomeStack(app, "some-test-naming") + yield app.synth().get_stack("some-test-naming").template + + +def test_resource_service_tokens(resource_naming_stack): + # There should be only one lambda function generated. + service_tokens = [ + resource["Properties"]["ServiceToken"] + for resource in resource_naming_stack["Resources"].values() + if resource["Type"] == "Custom::ResourceName" + ] + assert all(st == service_tokens[0] for st in service_tokens) diff --git a/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash.py b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash.py new file mode 100644 index 0000000..0e3662d --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash.py @@ -0,0 +1,71 @@ +# ##################################################################################################################### +# 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 pytest + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_hash.src.custom_resources.hash import ( + generate_hash, + get_property, + helper, +) + +EXPECTED_DIGEST = "DCB88E2D2EC20C11929E7C2C0366FEB6" + + +@pytest.fixture() +def lambda_event(): + event = { + "StackId": f"arn:aws:cloudformation:us-west-2:{''.join([str(i % 10) for i in range(1,13)])}:stack/stack-name/guid", + "ResourceProperties": { + "Purpose": "set-me", + "MaxLength": 64, + }, + } + yield event + + +def test_generate_hashed_name(lambda_event): + generate_hash(lambda_event, None) + assert ( + helper.Data["Name"] + == f"{lambda_event['ResourceProperties']['Purpose']}-{EXPECTED_DIGEST[:8]}" + ) + + +def test_generate_hashed_name_long(lambda_event): + lambda_event["ResourceProperties"]["Purpose"] = "a" * (64 - 9) + generate_hash(lambda_event, None) + assert ( + helper.Data["Name"] + == f"{lambda_event['ResourceProperties']['Purpose']}-{EXPECTED_DIGEST[:8]}" + ) + + +def test_generate_hashed_name_long(lambda_event): + lambda_event["ResourceProperties"]["Purpose"] = "a" * (64 - 8) + with pytest.raises(ValueError): + generate_hash(lambda_event, None) + + +def test_get_property_present(lambda_event): + assert get_property(lambda_event, "MaxLength") == 64 + + +def test_get_property_default(lambda_event): + assert get_property(lambda_event, "MissingProperty", "DEFAULT") == "DEFAULT" + + +def test_get_property_missing(lambda_event): + with pytest.raises(ValueError): + get_property(lambda_event, "MissingProperty") diff --git a/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash_cdk.py b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash_cdk.py new file mode 100644 index 0000000..691ada7 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/resource_name/test_resource_hash_cdk.py @@ -0,0 +1,44 @@ +# ##################################################################################################################### +# 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 pytest +from aws_cdk.core import Stack, App +from constructs import Construct + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.resource_name.name import ( + ResourceName, +) + + +class SomeStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + self.name_1 = ResourceName(self, "name_1", purpose="var_1", max_length=32) + self.name_2 = ResourceName(self, "name_2", purpose="var_2", max_length=32) + + +@pytest.fixture +def resource_naming_stack(): + app = App() + SomeStack(app, "some-test-naming") + yield app.synth().get_stack("some-test-naming").template + + +def test_resource_service_tokens(resource_naming_stack): + # There should be only one lambda function generated. + service_tokens = [ + resource["Properties"]["ServiceToken"] + for resource in resource_naming_stack["Resources"].values() + if resource["Type"] == "Custom::ResourceName" + ] + assert all(st == service_tokens[0] for st in service_tokens) diff --git a/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_cdk.py b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_cdk.py new file mode 100644 index 0000000..7c3a529 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_cdk.py @@ -0,0 +1,49 @@ +# ##################################################################################################################### +# 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 pytest +from aws_cdk.core import Stack, App +from constructs import Construct + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.solutions_metrics.metrics import ( + Metrics, +) + +ADDITIONAL_METRICS_VALID = { + "one": 1, + "two": {"three": "3"}, +} + + +class SomeStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + Metrics(self, construct_id, dict(**ADDITIONAL_METRICS_VALID)) + + +@pytest.fixture +def test_stack_metrics(): + app = App() + SomeStack(app, "some-test-metrics") + yield app.synth().get_stack("some-test-metrics").template + + +def test_metrics_valid(test_stack_metrics): + metric_resource = test_stack_metrics["Resources"]["SolutionMetricsAnonymousData"] + + assert metric_resource["Type"] == "Custom::AnonymousData" + assert all( + metric_resource["Properties"][k] == v + for k, v in ADDITIONAL_METRICS_VALID.items() + ) + assert metric_resource["Properties"]["Region"]["Ref"] == "AWS::Region" diff --git a/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_resource.py b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_resource.py new file mode 100644 index 0000000..2c0aaaa --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/cfn_custom_resources/solution_metrics/test_metrics_resource.py @@ -0,0 +1,144 @@ +# ##################################################################################################################### +# 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 logging +import os +from uuid import UUID + +import pytest +import requests + +from aws_solutions.cdk.aws_lambda.cfn_custom_resources.solutions_metrics.src.custom_resources.metrics import ( + helper, + send_metrics, + _sanitize_data, +) + +MOCKER_METRICS_ENDPOINT = "aws_solutions.cdk.aws_lambda.cfn_custom_resources.solutions_metrics.src.custom_resources.metrics.METRICS_ENDPOINT" +MOCKER_REQUESTS = "aws_solutions.cdk.aws_lambda.cfn_custom_resources.solutions_metrics.src.custom_resources.metrics.requests" + + +@pytest.fixture(params=["Create", "Update", "Delete"]) +def test_event(request): + event = { + "RequestType": request.param, + "ResourceProperties": {"Solution": "SOL0123", "Metric1": "Data1"}, + } + yield event + + +def test_sanitize_data(): + event = { + "RequestType": "Create", + "ResourceProperties": { + "ServiceToken": "REMOVEME", + "Resource": "REMOVEME", + "Solution": "REMOVEME", + "UUID": "REMOVEME", + "Keep": "Me", + }, + } + + result = _sanitize_data(event) + assert result == {"Keep": "Me", "CFTemplate": "Created"} + + +def test_send_metrics(test_event): + test_event["ResourceProperties"]["Resource"] = "UUID" + send_metrics(test_event, None) + + # raises a ValueError if we didn't get a uuid back + UUID(helper.Data["UUID"], version=4) + + +def test_send_metrics_real(test_event, mocker): + metrics_endpoint = os.getenv("METRICS_ENDPOINT") + if metrics_endpoint: + mocker.patch( + MOCKER_METRICS_ENDPOINT, + metrics_endpoint, + ) + send_metrics(test_event, None) + + +def test_send_metrics(mocker, test_event): + requests_mock = mocker.MagicMock() + mock_endpoint = "https://metrics-endpoint.com/example" + mocker.patch(MOCKER_REQUESTS, requests_mock) + mocker.patch( + MOCKER_METRICS_ENDPOINT, + mock_endpoint, + ) + + result = send_metrics(test_event, None) + assert UUID(result, version=4) + + assert requests_mock.post.call_args[0][0] == mock_endpoint + + request_data = requests_mock.post.call_args[1].get("json") + assert request_data.get("Solution") == "SOL0123" + assert request_data.get("UUID") + assert request_data.get("TimeStamp") + + data = request_data.get("Data") + assert data.get("Metric1") == "Data1" + assert data.get("CFTemplate") in ["Created", "Deleted", "Updated"] + + headers = requests_mock.post.call_args[1].get("headers") + assert headers.get("Content-Type") == "application/json" + + +def test_uuid_reuse(mocker, test_event): + requests_mock = mocker.MagicMock() + mocker.patch(MOCKER_REQUESTS, requests_mock) + uuid_to_set = "b14cc738-4c6c-42eb-b39b-4506a6a76911" + + if test_event.get("RequestType") == "Create": + # on create, we CloudFormation doesn't send a UUID + generated_uuid = send_metrics(test_event, None) + assert UUID(generated_uuid, version=4) + else: + # on update/ delete, CloudFormation sends a UUID, and the custom resource should return it as passed + test_event["PhysicalResourceId"] = uuid_to_set + generated_uuid = send_metrics(test_event, None) + assert generated_uuid == uuid_to_set + + +def test_request_exception(mocker, test_event, caplog): + requests_mock = mocker.MagicMock() + mocker.patch(MOCKER_REQUESTS, requests_mock) + requests_mock.exceptions.RequestException = requests.exceptions.RequestException + requests_mock.post.side_effect = requests.exceptions.ConnectionError( + "there was a connection error" + ) + + with caplog.at_level(logging.INFO): + send_metrics(test_event, None) + + assert ( + "Could not send usage data: there was a connection error" + ) in caplog.messages + + +def test_general_exception(mocker, test_event, caplog): + requests_mock = mocker.MagicMock() + mocker.patch(MOCKER_REQUESTS, requests_mock) + requests_mock.exceptions.RequestException = requests.exceptions.RequestException + requests_mock.post.side_effect = ValueError("general exception") + + with caplog.at_level(logging.INFO): + send_metrics(test_event, None) + + assert ( + "Unknown error when trying to send usage data: general exception" + ) in caplog.messages diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/build.gradle b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/build.gradle new file mode 100644 index 0000000..f2ffd6d --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'com.amazonaws:aws-lambda-java-core:1.2.1' + implementation 'com.amazonaws:aws-lambda-java-events:3.1.0' + runtimeOnly 'com.amazonaws:aws-lambda-java-log4j2:1.2.0' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' +} + +test { + useJUnitPlatform() +} + +task packageFat(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageLibs(type: Zip) { + into('java/lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageSkinny(type: Zip) { + from compileJava + from processResources +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +build.dependsOn packageSkinny \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradle/wrapper/gradle-wrapper.jar b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

    K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradle/wrapper/gradle-wrapper.properties b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..da9702f --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradlew b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/settings.gradle b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/settings.gradle new file mode 100644 index 0000000..d09d95e --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'java_sample' + diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/Handler.java b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/Handler.java new file mode 100644 index 0000000..f6781c1 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/Handler.java @@ -0,0 +1,14 @@ +package example; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +public class Handler implements RequestHandler { + + @Override + public UserData handleRequest(UserData input, Context context) { + UserData output = input; + output.setGreeting("Hello there " + input.getName()); + return output; + } +} diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/UserData.java b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/UserData.java new file mode 100644 index 0000000..66c0079 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/main/java/example/UserData.java @@ -0,0 +1,30 @@ +package example; + +public class UserData { + public UserData() { + } + + public UserData(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + private String name = ""; + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + private String greeting = ""; +} diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/test/java/example/HandlerTest.java b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/test/java/example/HandlerTest.java new file mode 100644 index 0000000..50813b4 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/fixtures/java_sample/src/test/java/example/HandlerTest.java @@ -0,0 +1,23 @@ +package example; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class HandlerTest { + Handler handler; + UserData userData; + + @BeforeEach + void setUp() { + handler = new Handler(); + userData = new UserData("AWS Solutions"); + } + + @Test + void handleRequest() { + UserData result = this.handler.handleRequest(userData, null); + assert result.getGreeting().equals("Hello there AWS Solutions"); + } +} \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/java/test_java_function.py b/source/tests/cdk_solution_helper/aws_lambda/java/test_java_function.py new file mode 100644 index 0000000..496770c --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/java/test_java_function.py @@ -0,0 +1,60 @@ +# ##################################################################################################################### +# 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 logging +from pathlib import Path + +import pytest +from aws_cdk.core import ( + Stack, + Construct, + App, +) + +from aws_solutions.cdk.aws_lambda.java.function import SolutionsJavaFunction + + +@pytest.fixture +def java_function_synth(caplog): + class FunctionStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + project_path = Path(__file__).parent.resolve() / "fixtures" / "java_sample" + distribution_path = project_path / "build" / "distributions" + + func = SolutionsJavaFunction( + self, + "TestFunction", + project_path=project_path, + distribution_path=distribution_path, + gradle_task="packageFat", + gradle_test="test", + handler="example.Handler", + ) + func.node.default_child.override_logical_id("TestFunction") + + with caplog.at_level(logging.DEBUG): + app = App() + FunctionStack(app, "test-function-lambda") + synth = app.synth() + print(f"CDK synth directory: {synth.directory}") + yield synth + + +@pytest.mark.no_cdk_lambda_mock +def test_java_function_synth(java_function_synth): + function_stack = java_function_synth.get_stack("test-function-lambda").template + func = function_stack["Resources"]["TestFunction"] + + assert func["Type"] == "AWS::Lambda::Function" + assert func["Properties"]["Runtime"] == "java11" diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/Pipfile b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/Pipfile new file mode 100644 index 0000000..4a7f9fe --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/Pipfile @@ -0,0 +1,2 @@ +[packages] +minimal = {path = "./package"} \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/a/z.txt b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/a/z.txt new file mode 100644 index 0000000..2e65efe --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/a/z.txt @@ -0,0 +1 @@ +a \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/c.txt b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/c.txt new file mode 100644 index 0000000..3410062 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/c.txt @@ -0,0 +1 @@ +c \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/z/a.txt b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/z/a.txt new file mode 100644 index 0000000..fa7af8b --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/hash_fixture/z/a.txt @@ -0,0 +1 @@ +z \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/minimal/__init__.py b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/minimal/__init__.py new file mode 100644 index 0000000..bce76b6 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/minimal/__init__.py @@ -0,0 +1,16 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + + +def function_in_package(): + return "hello from function_in_package" diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/setup.py b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/setup.py new file mode 100644 index 0000000..f0c38cd --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/lambda/package/setup.py @@ -0,0 +1,23 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from setuptools import setup + +setup( + name="minimal", + version="0.1", + description="a small package for testing", + author="AWS Solutions Builders", + packages=["minimal"], + zip_safe=True, +) diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/pyproject.toml b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/pyproject.toml new file mode 100644 index 0000000..325a985 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "python-bundling" +version = "0.1.0" +description = "" +authors = ["AWS Solutions Builders"] + +[tool.poetry.dependencies] +python = "^3.7" +minimal = {path = "package"} + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/requirements.txt b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/requirements.txt new file mode 100644 index 0000000..0de9fb6 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/fixtures/requirements.txt @@ -0,0 +1 @@ +./package \ No newline at end of file diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/test_function.py b/source/tests/cdk_solution_helper/aws_lambda/python/test_function.py new file mode 100644 index 0000000..77386f7 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/test_function.py @@ -0,0 +1,172 @@ +# ##################################################################################################################### +# 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 logging +import shutil +from pathlib import Path + +import pytest +from aws_cdk.core import Construct, Stack, App + +from aws_solutions.cdk.aws_lambda.python.function import ( + SolutionsPythonFunction, + DirectoryHash, +) +from aws_solutions.cdk.helpers.copytree import copytree + +PYTHON_FUNCTION_NAME = "user_python_lambda_function.py" +PYTHON_FUNCTION_HANDLER_NAME = "my_handler" +PYTHON_FUNCTION = f""" +def {PYTHON_FUNCTION_HANDLER_NAME}(event, context): + print("Hello World!") +""" + + +@pytest.fixture(params=["requirements.txt", "Pipfile", "pyproject.toml"]) +def python_lambda(tmp_path, request): + requirements = request.param + + entrypoint = tmp_path / PYTHON_FUNCTION_NAME + entrypoint.write_text(PYTHON_FUNCTION) + + # copy lambda function + lambda_function = Path(__file__).parent / "fixtures" / "lambda" + copytree(lambda_function, tmp_path) + + # copy requirements + shutil.copy(Path(__file__).parent / "fixtures" / requirements, tmp_path) + + yield entrypoint, PYTHON_FUNCTION_HANDLER_NAME, requirements + + +@pytest.fixture +def function_synth(python_lambda, caplog): + entrypoint, function_name, _ = python_lambda + + class FunctionStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + func = SolutionsPythonFunction( + self, + "TestFunction", + entrypoint=entrypoint, + function=function_name, + ) + func.node.default_child.override_logical_id("TestFunction") + + with caplog.at_level(logging.DEBUG): + app = App() + FunctionStack(app, "test-function") + synth = app.synth() + print(f"CDK synth directory: {synth.directory}") + yield synth + + +def test_function_has_default_role(function_synth): + function_stack = function_synth.get_stack("test-function").template + func = function_stack["Resources"]["TestFunction"] + assert func["Type"] == "AWS::Lambda::Function" + assert ( + func["Properties"]["Handler"] + == PYTHON_FUNCTION_NAME.split(".")[0] + "." + PYTHON_FUNCTION_HANDLER_NAME + ) + assert func["Properties"]["Runtime"] == "python3.7" + + role = function_stack["Resources"][func["Properties"]["Role"]["Fn::GetAtt"][0]] + assert role["Type"] == "AWS::IAM::Role" + assert role["Properties"] == { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + } + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "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/lambda/*", + ], + ] + }, + } + ], + "Version": "2012-10-17", + }, + "PolicyName": "LambdaFunctionServiceRolePolicy", + } + ], + } + + +@pytest.mark.no_cdk_lambda_mock +def test_library_packaging(python_lambda): + entrypoint, function_name, requirements = python_lambda + if requirements != "requirements.txt": + pytest.skip(f"not testing with {requirements}") + + package_dir = entrypoint.parent + Path(entrypoint.parent / "shared").mkdir() + Path(entrypoint.parent / "shared" / "__init__.py").touch() + Path(entrypoint.parent / "shared" / "lib.py").touch() + + class FunctionStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + func = SolutionsPythonFunction( + self, + "TestFunction", + entrypoint=entrypoint, + function=function_name, + libraries=Path(package_dir / "shared"), + ) + func.node.default_child.override_logical_id("TestFunction") + + app = App() + FunctionStack(app, "test-function") + synth = app.synth() + print(f"CDK synth directory: {synth.directory}") + + assert ( + next(Path(synth.directory).glob("asset.*")) / "shared" / "__init__.py" + ).exists() + assert (next(Path(synth.directory).glob("asset.*")) / "shared" / "lib.py").exists() + + +def test_directory_hash(): + fixture_path = Path(__file__).parent / "fixtures" / "hash_fixture" + r1 = DirectoryHash.hash(fixture_path) + assert r1 == "817e97ccbdadc94c102e5f193e079b91d1123a1f" # hash of 'caz' + + r2 = DirectoryHash.hash(fixture_path, fixture_path) + assert r2 == "964c581070520bb9726cd9a0996ebc28a4981bc6" # hash of 'cazcaz' diff --git a/source/tests/cdk_solution_helper/aws_lambda/python/test_layer_version.py b/source/tests/cdk_solution_helper/aws_lambda/python/test_layer_version.py new file mode 100644 index 0000000..efe55f0 --- /dev/null +++ b/source/tests/cdk_solution_helper/aws_lambda/python/test_layer_version.py @@ -0,0 +1,77 @@ +# ##################################################################################################################### +# 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 json +import logging +import shutil +from pathlib import Path + +import pytest +from aws_cdk.core import Construct, Stack, App + +from aws_solutions.cdk.aws_lambda.python.layer import SolutionsPythonLayerVersion +from aws_solutions.cdk.helpers.copytree import copytree + + +@pytest.fixture(params=["requirements.txt"]) +def python_layer_dir(tmp_path, request): + requirements = request.param + + entrypoint = tmp_path + + # copy lambda function + lambda_function = Path(__file__).parent / "fixtures" / "lambda" + copytree(lambda_function, tmp_path) + + # copy requirements + shutil.copy(Path(__file__).parent / "fixtures" / requirements, tmp_path) + + yield entrypoint + + +@pytest.fixture +def layer_synth(python_layer_dir, caplog): + source_path = python_layer_dir + + class LayerVersionStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + func = SolutionsPythonLayerVersion( + self, + "TestLayerVersion", + requirements_path=source_path, + ) + func.node.default_child.override_logical_id("TestLayerVersion") + + with caplog.at_level(logging.DEBUG): + app = App() + LayerVersionStack(app, "test-layer-version") + synth = app.synth() + print(f"CDK synth directory: {synth.directory}") + yield synth + + +@pytest.mark.no_cdk_lambda_mock +def test_layer_version(layer_synth): + layer_synth.get_stack("test-layer-version").template + directory = Path(layer_synth.directory) + manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8")) + + asset_dir = ( + directory + / manifest["artifacts"]["test-layer-version"]["metadata"][ + "/test-layer-version" + ][0]["data"]["path"] + ) + + # check that the package was installed to the correct path + assert (asset_dir / "python" / "minimal").exists() diff --git a/source/tests/cdk_solution_helper/helpers/test_load_cdk_app.py b/source/tests/cdk_solution_helper/helpers/test_load_cdk_app.py new file mode 100644 index 0000000..0376237 --- /dev/null +++ b/source/tests/cdk_solution_helper/helpers/test_load_cdk_app.py @@ -0,0 +1,136 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### +from pathlib import Path + +import pytest + +from aws_solutions.cdk.helpers.loader import load_cdk_app, CDKLoaderException + +CDK_APP = """ +from aws_cdk.core import App, Stack, Construct + +class EmptyStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs): + super().__init__(scope, construct_id) + +def cdk(): + app = App() + stack = EmptyStack(app, 'empty-stack') + return app.synth() +""" + +CDK_APP_BAD = """ +hey, this isn't valid python! +""" + +CDK_JSON = """ +{ + "app": "python3 deploy.py", + "context": {} +} +""" + +CDK_JSON_BAD = """ +{ this is not json } +""" + +CDK_JSON_MISSING_APP = """ +{ + "context": {} +} +""" + +CDK_JSON_MISSING_PYTHON3 = """ +{ + "app": "node index", + "context": {} +} +""" + + +@pytest.fixture +def cdk_app(tmp_path): + deploy_py = Path(tmp_path / "deploy.py") + cdk_json = Path(tmp_path / "cdk.json") + + deploy_py.write_text(CDK_APP) + cdk_json.write_text(CDK_JSON) + + yield (tmp_path, deploy_py, cdk_json) + + +@pytest.fixture +def cdk_app_bad(tmp_path): + deploy_py = Path(tmp_path / "deploy.py") + cdk_json = Path(tmp_path / "cdk.json") + + yield (tmp_path, deploy_py, cdk_json) + + +def test_load_cdk_app(cdk_app): + _, deploy_py, _ = cdk_app + cdk_entrypoint = load_cdk_app(deploy_py, "deploy:cdk") + + assert cdk_entrypoint.__name__ == "cdk" + assert callable(cdk_entrypoint) + result = cdk_entrypoint() + + stack = result.get_stack("empty-stack") + assert stack.template == {} # the stack generated should be empty + + +@pytest.mark.parametrize( + "deploy_py_content, cdk_json_content, entrypoint", + [ + (CDK_APP_BAD, CDK_JSON, "deploy:cdk"), + (CDK_APP, CDK_JSON_BAD, "deploy:cdk"), + (CDK_APP, CDK_JSON, "deploy"), + (CDK_APP, CDK_JSON, "deploy.something_else:invalid"), + (None, CDK_JSON, "deploy:cdk"), + (CDK_APP, None, "deploy:cdk"), + (CDK_APP, CDK_JSON_MISSING_APP, "deploy:cdk"), + (CDK_APP, CDK_JSON_MISSING_PYTHON3, "deploy:cdk"), + ], + ids=[ + "bad_app", + "bad_json", + "bad_entrypoint", + "worse_entrypoint", + "missing_app", + "missing_json", + "missing_app", + "missing_python3", + ], +) +def test_load_cdk_app_invalid( + cdk_app_bad, deploy_py_content, cdk_json_content, entrypoint +): + tmp_path, deploy_py, cdk_json = cdk_app_bad + + if deploy_py_content: + deploy_py.write_text(deploy_py_content) + else: + try: + deploy_py.unlink() + except FileNotFoundError: + pass + if cdk_json_content: + cdk_json.write_text(cdk_json_content) + else: + try: + cdk_json.unlink() + except FileNotFoundError: + pass + + with pytest.raises(CDKLoaderException): + load_cdk_app(deploy_py, entrypoint) diff --git a/source/tests/cdk_solution_helper/helpers/test_logger.py b/source/tests/cdk_solution_helper/helpers/test_logger.py new file mode 100644 index 0000000..efbb0c6 --- /dev/null +++ b/source/tests/cdk_solution_helper/helpers/test_logger.py @@ -0,0 +1,33 @@ +# ##################################################################################################################### +# 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 logging + +from aws_solutions.cdk.helpers.logger import Logger + + +def test_logger(caplog): + logger = Logger.get_logger("test-logger") + logger.propagate = True # for test + + assert logger.level == logging.INFO + + with caplog.at_level(logging.INFO): + logger.critical("CRITICAL") + logger.error("ERROR") + logger.warning("WARNING") + logger.info("INFO") + logging.debug("DEBUG") + + for level in "CRITICAL ERROR WARNING INFO".split(" "): + assert level in caplog.text diff --git a/source/tests/cdk_solution_helper/test_aspects.py b/source/tests/cdk_solution_helper/test_aspects.py new file mode 100644 index 0000000..f694e91 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_aspects.py @@ -0,0 +1,56 @@ +# ###################################################################################################################### +# 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 pytest +from aws_cdk.aws_sqs import Queue, CfnQueue +from aws_cdk.core import App, Stack, Construct, Aspects, CfnCondition, Fn + +from aws_solutions.cdk.aspects import ConditionalResources + + +class SomeConstruct(Construct): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + q1 = Queue(self, "TestQueue1") + q1.node.default_child.override_logical_id("TestQueue1") + q2 = Queue(self, "TestQueue2") + q2.node.default_child.override_logical_id("TestQueue2") + q3 = CfnQueue(self, "TestQueu3") + q3.override_logical_id("TestQueue3") + + +class SomeStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + condition = CfnCondition( + self, "SomeCondition", expression=Fn.condition_equals("1", "1") + ) + queues = SomeConstruct(self, "SomeQueues") + Aspects.of(queues).add(ConditionalResources(condition)) + + +@pytest.fixture +def stack_conditional(): + app = App() + SomeStack(app, "some-test-queues") + yield app.synth().get_stack("some-test-queues").template + + +def test_conditional_resources(stack_conditional): + assert stack_conditional["Conditions"]["SomeCondition"]["Fn::Equals"] == [ + "1", + "1", + ] + for k, v in stack_conditional["Resources"].items(): + assert v["Condition"] == "SomeCondition" diff --git a/source/tests/cdk_solution_helper/test_build_s3_cdk_dist.py b/source/tests/cdk_solution_helper/test_build_s3_cdk_dist.py new file mode 100644 index 0000000..08a614b --- /dev/null +++ b/source/tests/cdk_solution_helper/test_build_s3_cdk_dist.py @@ -0,0 +1,162 @@ +# ###################################################################################################################### +# 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. # +# ###################################################################################################################### +from pathlib import Path + +import boto3 +import botocore +import click +import pytest +from moto import mock_s3, mock_sts + +from aws_solutions.cdk.scripts.build_s3_cdk_dist import ( + PathPath, + BuildEnvironment, + RegionalAssetPackager, + GlobalAssetPackager, + validate_version_code, + BaseAssetPackager, +) + +TEST_VERSION_CODE = "v1.0.0" + + +@pytest.fixture +def default_build_environment(): + build = BuildEnvironment( + source_bucket_name="source_bucket", + solution_name="solution-name", + version_code=TEST_VERSION_CODE, + ) + gap = GlobalAssetPackager(build) + rap = RegionalAssetPackager(build) + gap.check_bucket = lambda: True + rap.check_bucket = lambda: True + + return gap, rap + + +def test_pathpath(): + pth = PathPath() + test_path = pth("test") + assert isinstance(test_path, Path) + + +def test_build_environment(): + build = BuildEnvironment( + source_bucket_name="source_bucket", + solution_name="solution-name", + version_code=TEST_VERSION_CODE, + ) + assert Path(build.template_dist_dir).stem == "global-s3-assets" + assert Path(build.build_dir).stem == "build-s3-assets" + assert Path(build.build_dist_dir).stem == "regional-s3-assets" + assert Path(build.source_dir).stem == "source" + assert Path(build.infrastructure_dir).parent.stem == "source" + + +def test_validate_version_code_valid(): + assert validate_version_code(None, None, TEST_VERSION_CODE) == TEST_VERSION_CODE + + +def test_validate_version_code_invalid(): + with pytest.raises(click.BadParameter): + assert validate_version_code(None, None, "1.0.0") + + +@pytest.mark.parametrize( + "packager_cls,expected_s3_path", + [ + (GlobalAssetPackager, "s3://source_bucket/solution-name/v1.0.0"), + (RegionalAssetPackager, "s3://source_bucket-us-east-1/solution-name/v1.0.0"), + ], +) +def test_global_asset_packager(packager_cls, expected_s3_path): + build = BuildEnvironment( + source_bucket_name="source_bucket", + solution_name="solution-name", + version_code=TEST_VERSION_CODE, + ) + packager = packager_cls(build) + + assert packager.s3_asset_path == expected_s3_path + + +def test_sync(mocker, default_build_environment): + packager, _ = default_build_environment + + mock = mocker.MagicMock() + type(mock.Popen.return_value.__enter__.return_value).stdout = mocker.PropertyMock( + return_value=["sync stdout result"] + ) + type(mock.Popen.return_value.__enter__.return_value).stderr = mocker.PropertyMock( + return_value=["sync stderr result"] + ) + type( + mock.Popen.return_value.__enter__.return_value + ).returncode = mocker.PropertyMock(return_value=0) + # fmt: off + mocker.patch("aws_solutions.cdk.scripts.build_s3_cdk_dist.subprocess", mock) # NOSONAR (python:S1192) - string for clarity + # fmt: on + packager.sync() + + +def test_sync_fail_no_awscli(mocker, default_build_environment): + packager, _ = default_build_environment + + mock = mocker.MagicMock() + mock.Popen.return_value.side_effect = FileNotFoundError() + # fmt: off + mocker.patch("aws_solutions.cdk.scripts.build_s3_cdk_dist.subprocess", mock) # NOSONAR (python:S1192) - string for clarity + # fmt: on + + with pytest.raises(click.ClickException): + packager.sync() + + +def test_sync_fail_no_successful_awscli(mocker, default_build_environment): + packager, _ = default_build_environment + + mock = mocker.MagicMock() + # fmt: off + mocker.patch("aws_solutions.cdk.scripts.build_s3_cdk_dist.subprocess", mock) # NOSONAR (python:S1192) - string for clarity + # fmt: on + type( + mock.Popen.return_value.__enter__.return_value + ).returncode = mocker.PropertyMock(return_value=1) + + with pytest.raises(click.ClickException): + packager.sync() + + +@mock_s3 +@mock_sts +def test_bucket_check_valid(): + s3 = boto3.client("s3", region_name="eu-central-1") + s3.create_bucket( + Bucket="MyBucket", + CreateBucketConfiguration={"LocationConstraint": "eu-central-1"}, + ) + + packager = BaseAssetPackager() + packager.s3_asset_path = "s3://MyBucket" + assert packager.check_bucket() + + +@mock_s3 +@mock_sts +def test_bucket_check_invalid(): + packager = BaseAssetPackager() + packager.s3_asset_path = "s3://MyBucket" + + with pytest.raises(botocore.exceptions.ClientError): + assert packager.check_bucket() diff --git a/source/tests/cdk_solution_helper/test_cdk_context.py b/source/tests/cdk_solution_helper/test_cdk_context.py new file mode 100644 index 0000000..8d75d61 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_cdk_context.py @@ -0,0 +1,117 @@ +# ###################################################################################################################### +# 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 json +from os import environ + +import pytest + +from aws_solutions.cdk.context import SolutionContext + +SOLUTION_NAME = "aws-solution-name" +SOLUTION_VERSION = "1.0.0" +SOLUTION_CDK_SCRIPT = "deploy.py" +CDK_JSON_TEXT = json.dumps( + { + "app": f"python3 {SOLUTION_CDK_SCRIPT}", + "context": { + "SOLUTION_NAME": SOLUTION_NAME, + "SOLUTION_VERSION": SOLUTION_VERSION, + "@aws-cdk/core:newStyleStackSynthesis": "true", + }, + } +) + + +@pytest.fixture +def cdk_json_path(tmp_path): + d = tmp_path / "cdk_dir" + d.mkdir() + p = d / "cdk.json" + p.write_text(CDK_JSON_TEXT) + yield p + + +def test_aws_solution_too_many_params(): + context = SolutionContext() + + @context.requires("SOLUTION_NAME") + def func_under_test(context, something_else): + pass # NOSONAR (python:S1186) - testing requires a function to annotate + + with pytest.raises(ValueError): + func_under_test("one", "two") + + +def test_aws_solution_wrong_param_type(): + context = SolutionContext() + + @context.requires("SOLUTION_NAME") + def func_under_test(context): + pass # NOSONAR (python:S1186) - testing requires a function to annotate + + with pytest.raises(TypeError): + func_under_test("one") + + +def test_aws_solution(cdk_json_path): + context = SolutionContext(cdk_json_path=cdk_json_path) + override = "overridden context" + solution_name = environ.get( + "SOLUTION_NAME", SOLUTION_NAME + ) # environment variable always wins + version = environ.get( + "SOLUTION_VERSION", "1.2.3" + ) # environment variable always wins + + @context.requires("SOLUTION_NAME") + @context.requires("SOLUTION_VERSION", version) + def func_under_test(context): + assert context["SOLUTION_NAME"] == solution_name + assert context["SOLUTION_VERSION"] == version + assert context["OVERRIDE"] == override + + func_under_test({"OVERRIDE": override}) + + +def test_aws_solution_env_vars(cdk_json_path): + context = SolutionContext(cdk_json_path) + + override = "overridden context" + solution_name_env = "from environment solution name" + solution_version_env = "from environment solution version" + environ["_SOLUTION_NAME"] = solution_name_env + environ["_SOLUTION_VERSION"] = solution_version_env + + @context.requires("_SOLUTION_NAME") + @context.requires("_SOLUTION_VERSION") + def func_under_test(context): + assert context["_SOLUTION_NAME"] == solution_name_env + assert context["_SOLUTION_VERSION"] == solution_version_env + assert context["_OVERRIDE"] == override + + func_under_test({"_OVERRIDE": override}) + + del environ["_SOLUTION_NAME"] + del environ["_SOLUTION_VERSION"] + + +def test_aws_solution_missing_context(cdk_json_path): + context = SolutionContext(cdk_json_path) + + @context.requires("NOT_PRESENT") + def func_under_test(): + pass # NOSONAR (python:S1186) - testing requires a function to annotate + + with pytest.raises(ValueError): + func_under_test() diff --git a/source/tests/cdk_solution_helper/test_cdk_interfaces.py b/source/tests/cdk_solution_helper/test_cdk_interfaces.py new file mode 100644 index 0000000..b346dd7 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_cdk_interfaces.py @@ -0,0 +1,63 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from pathlib import Path + +import pytest + +from aws_solutions.cdk.helpers import copytree + + +@pytest.fixture(scope="function") +def dir_to_copy(tmp_path): + Path(tmp_path / "exists" / "sub1" / "sub2").mkdir(parents=True) + Path(tmp_path / "exists" / "sub1" / "sub1_f").touch() + Path(tmp_path / "exists" / "sub1" / "sub2") + Path(tmp_path / "exists" / "sub1" / "sub2", "sub2_f").touch() + Path(tmp_path / "exists" / "subroot_f").touch() + Path(tmp_path / "other" / "sub3").mkdir(parents=True) + Path(tmp_path / "other" / "sub3" / "sub3_f").touch() + + yield tmp_path + + +def test_copytree_dir_exists(dir_to_copy): + Path(dir_to_copy / "new").mkdir() + copytree(src=dir_to_copy / "exists", dst=dir_to_copy / "new") + + assert Path(dir_to_copy / "new" / "sub1" / "sub1_f").exists() + assert Path(dir_to_copy / "new" / "sub1" / "sub2" / "sub2_f").exists() + assert Path(dir_to_copy / "new" / "subroot_f").exists() + + +def test_copytree_dir_does_not_exist(dir_to_copy): + copytree(src=dir_to_copy / "exists", dst=dir_to_copy / "new") + copytree(src=dir_to_copy / "other", dst=dir_to_copy / "new") + + assert Path(dir_to_copy / "new" / "sub1" / "sub1_f").exists() + assert Path(dir_to_copy / "new" / "sub1" / "sub2" / "sub2_f").exists() + assert Path(dir_to_copy / "new" / "subroot_f").exists() + assert Path(dir_to_copy / "new" / "sub3" / "sub3_f").exists() + + +def test_copytree_globs(dir_to_copy): + copytree( + src=dir_to_copy / "exists", + dst=dir_to_copy / "new", + ignore=["**/sub2/*", "subroot_f"], + ) + + assert not (Path(dir_to_copy) / "new" / "subroot_f").exists() + assert (Path(dir_to_copy) / "new" / "sub1").exists() + assert (Path(dir_to_copy) / "new" / "sub1" / "sub1_f").exists() + assert not (Path(dir_to_copy) / "new" / "sub1" / "sub2").exists() diff --git a/source/tests/cdk_solution_helper/test_cfn_nag_suppressions.py b/source/tests/cdk_solution_helper/test_cfn_nag_suppressions.py new file mode 100644 index 0000000..1bcb209 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_cfn_nag_suppressions.py @@ -0,0 +1,46 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from aws_cdk.core import CfnResource, App, Stack + +from aws_solutions.cdk.cfn_nag import add_cfn_nag_suppressions, CfnNagSuppression + + +def test_cfn_nag_suppression(): + rule_id = "W10" + reason = "some reason" + sup = CfnNagSuppression(rule_id=rule_id, reason=reason) + + assert sup.rule_id == rule_id + assert sup.reason == reason + + +def test_add_cfn_nag_suppression(): + app = App() + stack = Stack(app) + resource = CfnResource(stack, "test", type="Custom::Test") + + add_cfn_nag_suppressions( + resource, + [ + CfnNagSuppression(rule_id="W1", reason="reason 1"), + CfnNagSuppression("W2", "reason 2"), + ], + ) + + assert resource.get_metadata("cfn_nag") == { + "rules_to_suppress": [ + {"id": "W1", "reason": "reason 1"}, + {"id": "W2", "reason": "reason 2"}, + ] + } diff --git a/source/tests/cdk_solution_helper/test_helpers.py b/source/tests/cdk_solution_helper/test_helpers.py new file mode 100644 index 0000000..c75d191 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_helpers.py @@ -0,0 +1,70 @@ +# ###################################################################################################################### +# 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 os + +import pytest +from moto import mock_sts + +from aws_solutions.core import ( + get_aws_region, + get_service_client, + get_aws_partition, + get_aws_account, + get_service_resource, +) + + +@pytest.fixture(autouse=True, scope="module") +def valid_solution_env(): + os.environ["AWS_REGION"] = "us-east-1" + os.environ["SOLUTION_ID"] = "SO0100" + os.environ["SOLUTION_VERSION"] = "1.0.0" + yield + del os.environ["AWS_REGION"] + del os.environ["SOLUTION_ID"] + del os.environ["SOLUTION_VERSION"] + + +def test_get_aws_region_valid(): + assert get_aws_region() == "us-east-1" + + +def test_get_service_client(): + cli = get_service_client("ec2") + assert cli.meta.service_model.service_name == "ec2" + + +def test_get_service_resource(): + ec2 = get_service_resource("ec2") + assert ec2.meta.service_name == "ec2" + + +@pytest.mark.parametrize( + "region,partition", + [ + ("us-east-1", "aws"), + ("us-gov-west-1", "aws-us-gov"), + ("us-gov-west-2", "aws-us-gov"), + ("cn-north-1", "aws-cn"), + ("cn-northwest-1", "aws-cn"), + ], +) +def test_get_aws_partition(region, partition, mocker): + mocker.patch("aws_solutions.core.helpers.get_aws_region", return_value=region) + assert get_aws_partition() == partition + + +@mock_sts +def test_get_aws_account_id(mocker): + assert get_aws_account() == "1" * 12 diff --git a/source/tests/cdk_solution_helper/test_interfaces.py b/source/tests/cdk_solution_helper/test_interfaces.py new file mode 100644 index 0000000..ce8ba08 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_interfaces.py @@ -0,0 +1,135 @@ +# ###################################################################################################################### +# 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 json +from pathlib import Path + +import pytest +from aws_cdk.core import App, Stack, NestedStack, CfnParameter + +from aws_solutions.cdk.interfaces import ( + _TemplateParameter, + TemplateOptions, + TemplateOptionsException, +) + + +@pytest.fixture +def stacks(): + app = App() + stack = Stack(app, "stack-id-1") + nested_stack = Stack(stack, "stack-id-2") + nested_nestedstack = NestedStack(stack, "stack-id-3") + + TemplateOptions(stack, "id_1", "description_1", "stack_1.template") + TemplateOptions(nested_stack, "id_2", "description_2", "stack_2.template") + TemplateOptions(nested_nestedstack, "id_3", "description_3", "stack_3.template") + + synth = app.synth() + stacks = [ + json.loads(path.read_text()) + for path in Path(synth.directory).glob("*.template.json") + ] + + return stacks + + +def test_template_parameter(): + tp = _TemplateParameter("name", "label", "group") + assert tp.name == "name" + assert tp.label == "label" + assert tp.group == "group" + + +def test_template_options(stacks): + # stack 1 should reference stack 3 (NestedStack) + # stack 2 should be independent of the others + + for stack in stacks: + stack_description = stack["Description"] + stack_template_name = stack["Metadata"]["aws:solutions:templatename"] + + assert ( + stack_description.split("_")[-1] + == stack_template_name.split("_")[-1].split(".")[0] + ) + + # the only stack with resources will point to the nested stack + if stack.get("Resources"): + assert ( + list(stack["Resources"].values())[0]["Metadata"][ + "aws:solutions:templateid" + ] + == "id_3" + ) + assert ( + list(stack["Resources"].values())[0]["Metadata"][ + "aws:solutions:templatename" + ] + == "stack_3.template" + ) + assert len(stack["Resources"]) == 1 + + +def test_template_suffix(): + app = App() + stack = Stack(app, "stack-id-1") + + with pytest.raises(TemplateOptionsException): + TemplateOptions(stack, "id_1", "description_1", "stack_1.json") + + +def test_template_options_add_parameters(): + app = App() + stack = Stack(app, "stack-id-1") + template_options = TemplateOptions( + stack, "id_1", "description_1", "stack_1.template" + ) + template_options.add_parameter( + parameter=CfnParameter(stack, "parameter_1"), + label="parameter label 1", + group="group a", # NOSONAR (python:S1192) - string for clarity + ) + template_options.add_parameter( + parameter=CfnParameter(stack, "parameter_2"), + label="parameter label 2", + group="group b", + ) + template_options.add_parameter( + parameter=CfnParameter(stack, "parameter_3"), + label="parameter label 3", + group="group a", # NOSONAR (python:S1192) - string for clarity + ) + + template = app.synth().stacks[0].template + + parameter_groups = template["Metadata"]["AWS::CloudFormation::Interface"][ + "ParameterGroups" + ] + parameter_labels = template["Metadata"]["AWS::CloudFormation::Interface"][ + "ParameterLabels" + ] + + assert len(parameter_groups) == 2 + assert { + "Label": {"default": "group a"}, # NOSONAR (python:S1192) - string for clarity + "Parameters": ["parameter1", "parameter3"], + } in parameter_groups + assert { + "Label": {"default": "group b"}, + "Parameters": ["parameter2"], + } in parameter_groups + assert len(parameter_labels) == 3 + assert parameter_labels["parameter1"] == {"default": "parameter label 1"} + assert parameter_labels["parameter2"] == {"default": "parameter label 2"} + assert parameter_labels["parameter3"] == {"default": "parameter label 3"} diff --git a/source/tests/cdk_solution_helper/test_logging.py b/source/tests/cdk_solution_helper/test_logging.py new file mode 100644 index 0000000..e8c4c9d --- /dev/null +++ b/source/tests/cdk_solution_helper/test_logging.py @@ -0,0 +1,58 @@ +# ##################################################################################################################### +# 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 logging +import os + +import pytest + +from aws_solutions.core.logging import get_level, get_logger + + +@pytest.fixture(scope="function", autouse=True) +def reset_logging_defaults(): + """Remove any logging configuration defaults that might have existed before starting any test""" + try: + os.environ.pop("LOG_LEVEL") + except KeyError: + pass + + +@pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING", "ERROR"]) +def test_valid_levels(level): + os.environ["LOG_LEVEL"] = level + assert get_level() == level + + +def test_invalid_level(): + os.environ["LOG_LEVEL"] = "TRACE" + assert get_level() == "WARNING" + os.environ["LOG_LEVEL"] = "INFO" + + +def test_get_logger(): + logger = get_logger(__name__) + assert logger.level == logging.WARNING + + +def test_logger_log(caplog): + logger = get_logger(__name__) + logger.error("This is an error") + logger.warning("This is a warning") + logger.info("This is an informational message") + logger.debug("This is a debug message") + + assert "This is an error" in caplog.text + assert "This is a warning" in caplog.text + assert "This is an informational message" not in caplog.text + assert "This is a debug message" not in caplog.text diff --git a/source/tests/cdk_solution_helper/test_mappings.py b/source/tests/cdk_solution_helper/test_mappings.py new file mode 100644 index 0000000..f7588bd --- /dev/null +++ b/source/tests/cdk_solution_helper/test_mappings.py @@ -0,0 +1,38 @@ +# ###################################################################################################################### +# 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 pytest +from aws_cdk.core import App, Stack + +from aws_solutions.cdk.mappings import Mappings + + +@pytest.mark.parametrize("send_data,result", [(True, "Yes"), (False, "No")]) +def test_mappings(send_data, result): + solution_id = "SO001" + app = App() + stack = Stack(app) + Mappings(stack, solution_id=solution_id, send_anonymous_usage_data=send_data) + + template = app.synth().stacks[0].template + + assert template["Mappings"]["Solution"]["Data"]["ID"] == solution_id + assert template["Mappings"]["Solution"]["Data"]["Version"] == "%%SOLUTION_VERSION%%" + assert template["Mappings"]["Solution"]["Data"]["SendAnonymousUsageData"] == result + + assert ( + template["Mappings"]["SourceCode"]["General"]["S3Bucket"] == "%%BUCKET_NAME%%" + ) + assert ( + template["Mappings"]["SourceCode"]["General"]["KeyPrefix"] + == "%%SOLUTION_NAME%%/%%SOLUTION_VERSION%%" + ) diff --git a/source/tests/cdk_solution_helper/test_solution_config.py b/source/tests/cdk_solution_helper/test_solution_config.py new file mode 100644 index 0000000..e3406f5 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_solution_config.py @@ -0,0 +1,142 @@ +# ###################################################################################################################### +# 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 os + +import botocore +import pytest + +import aws_solutions.core + + +@pytest.fixture(scope="function", autouse=True) +def reset_botocore_config(): + """remove botocore configuration before test""" + aws_solutions.core.config._botocore_config = None + + +@pytest.fixture( + params=[ + "SO0100", + "SO0100a", + "SO0100A", + ] +) +def solution_id_valid(request): + solution_id = request.param + os.environ["SOLUTION_ID"] = solution_id + yield solution_id + del os.environ["SOLUTION_ID"] + + +@pytest.fixture(params=["S0100", "abc", "SO0x3ab"]) +def solution_id_invalid(request): + solution_id = request.param + os.environ["SOLUTION_ID"] = solution_id + yield solution_id + del os.environ["SOLUTION_ID"] + + +@pytest.fixture( + params=[ + "v1.0.0-alpha", + "v1.0.0-alpha.1", + "v1.0.0-alpha.beta", + "v1.0.0-beta", + "v1.0.0-beta.2", + "v1.0.0-beta.11", + "v1.0.0-rc.1", + "v1.0.0", + "v1.0.0-alpha", + "v1.0.0-alpha.1", + "v1.0.0-0.3.7", + "v1.0.0-x.7.z.92", + "v1.0.0-x-y-z.-", + "v1.0.0-alpha+001", + "v1.0.0+20130313144700", + "v1.0.0-beta+exp.sha.5114f85", + "v1.0.0+21AF26D3--117B344092BD", + ] +) +def solution_version_valid(request): + solution_version = request.param + os.environ["SOLUTION_VERSION"] = solution_version + yield solution_version + del os.environ["SOLUTION_VERSION"] + + +@pytest.fixture(params=["a.b.c", "a1.2.3", "v.1.2.3"]) +def solution_version_invalid(request): + solution_version = request.param + os.environ["SOLUTION_VERSION"] = solution_version + yield solution_version + del os.environ["SOLUTION_VERSION"] + + +def test_valid_solution_id(solution_id_valid): + config_id = aws_solutions.core.config.id + assert config_id == solution_id_valid + + +def test_invalid_solution_id(solution_id_invalid): + with pytest.raises(ValueError): + aws_solutions.core.config.id + + +def test_valid_solution_version(solution_version_valid): + version = aws_solutions.core.config.version + assert version == solution_version_valid + + +def test_invalid_solution_id(solution_version_invalid): + with pytest.raises(ValueError): + aws_solutions.core.config.version + + +def test_valid_botocore_config(solution_id_valid, solution_version_valid): + boto_config = aws_solutions.core.config.botocore_config + assert ( + boto_config.user_agent_extra + == f"AwsSolution/{solution_id_valid}/{solution_version_valid}" + ) + + +def test_solution_config_env_reuse(): + aws_solutions.core.config.id = "SO0100" + + id_1 = aws_solutions.core.config.id + id_2 = aws_solutions.core.config.id + + assert id_1 is id_2 + + +def test_botocore_config_change(): + aws_solutions.core.config.id = "SO0100" + aws_solutions.core.config.version = "v1.0.0" + aws_solutions.core.config.botocore_config.read_timeout = 123 + assert aws_solutions.core.config.botocore_config.read_timeout == 123 + + +def test_botocore_config_change_defaults(): + # it is probably better to just set the value directly as per above + aws_solutions.core.config.id = "SO0100" + aws_solutions.core.config.version = "v1.0.0" + aws_solutions.core.config.botocore_config + cfg_2 = botocore.config.Config(read_timeout=123) + aws_solutions.core.config.botocore_config = cfg_2 + + assert aws_solutions.core.config.botocore_config.read_timeout == 123 + assert ( + aws_solutions.core.config.botocore_config.user_agent_extra + == f"AwsSolution/SO0100/v1.0.0" + ) diff --git a/source/tests/cdk_solution_helper/test_stack.py b/source/tests/cdk_solution_helper/test_stack.py new file mode 100644 index 0000000..48e9e18 --- /dev/null +++ b/source/tests/cdk_solution_helper/test_stack.py @@ -0,0 +1,115 @@ +# ###################################################################################################################### +# 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 re + +import pytest +from aws_cdk.core import App + +from aws_solutions.cdk.stack import ( + SolutionStack, + validate_re, + validate_solution_id, + validate_template_filename, +) + + +@pytest.mark.parametrize( + "valid_solution_id", + [ + "SO0", + "SO0", + "SO000", + ], +) +def test_validate_solution_id_valid(valid_solution_id): + assert validate_solution_id(valid_solution_id) == valid_solution_id + + +@pytest.mark.parametrize( + "invalid_solution_id", + [ + "S00", + "SO0a", + "SO000b", + ], +) +def test_validate_solution_id_invalid(invalid_solution_id): + with pytest.raises(ValueError): + validate_solution_id(invalid_solution_id) + + +@pytest.mark.parametrize( + "valid_template_filename", + [ + "solution.template", + "solution-detail.template", + ], +) +def test_validate_template_filename_valid(valid_template_filename): + assert ( + validate_template_filename(valid_template_filename) == valid_template_filename + ) + + +@pytest.mark.parametrize( + "invalid_template_filename", + [ + "SOLUTION.template", + "solution.TEMPLATE", + "solution_detail.template", + "solution-.template", + ], +) +def test_validate_template_filename_invalid(invalid_template_filename): + with pytest.raises(ValueError): + validate_template_filename(invalid_template_filename) + + +def test_validate_re(): + regex = re.compile(r"\d") + assert validate_re("some", "1", regex) == "1" + + +def test_validate_re_exception(): + regex = re.compile(r"\d") + with pytest.raises(ValueError): + assert validate_re("some", "a", regex) + + +def test_solution_stack(): + stack_id = "S00123" + stack_description = "stack description" + stack_filename = "stack-name.template" + + app = App(context={"SOLUTION_ID": stack_id}) + SolutionStack(app, "stack", stack_description, stack_filename) + + template = app.synth().stacks[0].template + + assert template["Description"] == f"({stack_id}) {stack_description}" + assert template["Metadata"] == { + "AWS::CloudFormation::Interface": { + "ParameterGroups": [], + "ParameterLabels": {}, + }, + "aws:solutions:templatename": "stack-name.template", + } + assert template["Conditions"] == { + "SendAnonymousUsageData": { + "Fn::Equals": [ + {"Fn::FindInMap": ["Solution", "Data", "SendAnonymousUsageData"]}, + "Yes", + ] + } + } diff --git a/source/tests/cdk_solution_helper/test_synthesizers.py b/source/tests/cdk_solution_helper/test_synthesizers.py new file mode 100644 index 0000000..ab7e46b --- /dev/null +++ b/source/tests/cdk_solution_helper/test_synthesizers.py @@ -0,0 +1,76 @@ +# ###################################################################################################################### +# 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 os + +import pytest +from aws_cdk.core import App, Stack + +from aws_solutions.cdk.interfaces import TemplateOptions +from aws_solutions.cdk.mappings import Mappings +from aws_solutions.cdk.synthesizers import ( + SolutionStackSubstitions, +) + + +@pytest.fixture +def solution_build_environment(monkeypatch): + monkeypatch.setenv("SOLUTIONS_ASSETS_GLOBAL", "global-s3-assets") + monkeypatch.setenv("SOLUTIONS_ASSETS_REGIONAL", "regional-s3-assets") + yield + monkeypatch.delenv("SOLUTIONS_ASSETS_GLOBAL") + monkeypatch.delenv("SOLUTIONS_ASSETS_REGIONAL") + + +@pytest.fixture +def template(): + context = { + "SOLUTION_VERSION": "v1.0.0", + "SOLUTION_NAME": "test-solution-name", + "BUCKET_NAME": "test-solution-bucket", + } + for ctx_var in ["SOLUTIONS_ASSETS_GLOBAL", "SOLUTIONS_ASSETS_REGIONAL"]: + ctx_var_val = os.environ.get(ctx_var) + if ctx_var_val: + context[ctx_var] = ctx_var_val + + synth = SolutionStackSubstitions() + app = App(context=context) + stack = Stack(app, "stack-id-1", synthesizer=synth) + TemplateOptions(stack, "id_1", "description_1", "stack_1.template") + Mappings(stack, "SO001") + + # SOLUTIONS_ASSETS_GLOBAL / SOLUTIONS_ASSETS_REGIONAL not set: + # this will not remove the CDK generated parameters (this was called by CDK) + yield app.synth().stacks[0].template + + +def test_cloudformation_template_init(template): + assert template["Parameters"] + assert template["Rules"]["CheckBootstrapVersion"] + + +def test_cloudformation_template_init_metadata(solution_build_environment, template): + assert not template.get("Parameters") + assert not template.get("Rules") + + assert template["Metadata"]["aws:solutions:templatename"] == "stack_1.template" + assert template["Mappings"]["Solution"] == { + "Data": {"ID": "SO001", "Version": "v1.0.0", "SendAnonymousUsageData": "Yes"} + } + assert template["Mappings"]["SourceCode"] == { + "General": { + "S3Bucket": "test-solution-bucket", + "KeyPrefix": "test-solution-name/v1.0.0", + } + } diff --git a/source/tests/cdk_solution_helper/tools/test_cleaner.py b/source/tests/cdk_solution_helper/tools/test_cleaner.py new file mode 100644 index 0000000..59c50fc --- /dev/null +++ b/source/tests/cdk_solution_helper/tools/test_cleaner.py @@ -0,0 +1,112 @@ +# ##################################################################################################################### +# 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. # +# ##################################################################################################################### + +from pathlib import Path + +import pytest + +from aws_solutions.cdk.tools import Cleaner +from aws_solutions.cdk.tools.cleaner import Cleanable + + +@pytest.fixture +def directory_to_clean(tmp_path): + build_s3_assets = tmp_path / "build-s3-assets" + global_s3_assets = tmp_path / "global-s3-assets" + regional_s3_assets = tmp_path / "regional-s3-assets" + + build_s3_assets.mkdir() + global_s3_assets.mkdir() + regional_s3_assets.mkdir() + + build_asset = build_s3_assets / "build_asset" + global_asset = global_s3_assets / "global_asset" + regional_asset = regional_s3_assets / "regional_asset" + + build_asset.touch() + regional_asset.touch() + global_asset.touch() + + yield build_s3_assets, global_s3_assets, regional_s3_assets + + +@pytest.mark.parametrize( + "to_delete, is_file", + [ + ("python_bytecode_1.pyc", True), + ("python_bytecode_2.pyo", True), + ("python_bytecode_3.pyd", True), + (".coverage", True), + ("cdk.out", False), + ("some.egg-info", False), + ("__pycache__", False), + ], + ids=["pyc", "pyo", "pyd", "coverage", "cdk", "egg_info", "pycache"], +) +def test_cleanup_source(directory_to_clean, to_delete, is_file): + build_s3_assets, _, _ = directory_to_clean + + if is_file: + Path(build_s3_assets / to_delete).touch() + else: + Path(build_s3_assets / to_delete).mkdir() + + # cleaner should recurse into the directory and clean up the file(s)/ dirs + Cleaner.cleanup_source(build_s3_assets.parent) + + assert not Path(build_s3_assets / to_delete).exists() + + +def test_clean_dirs(directory_to_clean): + build_s3_assets, global_s3_assets, regional_s3_assets = directory_to_clean + assert build_s3_assets.is_dir() + assert global_s3_assets.is_dir() + assert regional_s3_assets.is_dir() + + Cleaner.clean_dirs(build_s3_assets, global_s3_assets, regional_s3_assets) + + assert build_s3_assets.is_dir() + assert global_s3_assets.is_dir() + assert regional_s3_assets.is_dir() + + assert not (build_s3_assets / "build_asset").exists() + assert not (global_s3_assets / "global_asset").exists() + assert not (regional_s3_assets / "regional_asset").exists() + + +def test_cleanble(tmp_path): + f1 = tmp_path / "test1-abc" + f2 = tmp_path / "test2-abc" + + d1 = tmp_path / "dir" + f3 = d1 / "test1-abc" + + f1.touch() + f2.touch() + d1.mkdir() + f3.touch() + + assert all([f1.exists(), f2.exists(), f3.exists()]) + + to_clean = Cleanable(name="test1", file_type="f", pattern="test1*") + to_clean.delete(tmp_path) + + assert not f1.exists() + assert f2.exists() + assert not f3.exists() + assert d1.exists() + + to_clean = Cleanable(name="dir", file_type="d", pattern="dir") + to_clean.delete(tmp_path) + + assert not d1.exists() diff --git a/source/tests/conftest.py b/source/tests/conftest.py new file mode 100644 index 0000000..37f79f5 --- /dev/null +++ b/source/tests/conftest.py @@ -0,0 +1,125 @@ +# ###################################################################################################################### +# 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 os +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import jsii +import pytest +from aws_cdk.aws_lambda import ( + FunctionProps, + Code, + Runtime, + Function, + LayerVersionProps, + LayerVersion, +) +from aws_cdk.core import Construct +from botocore.stub import Stubber + +from aws_solutions.core import get_service_client + +shared_path = str(Path(__file__).parent.parent / "aws_lambda") +if shared_path not in sys.path: + sys.path.insert(0, shared_path) + + +class Solution: + id = "SO0170test" + version = "99.99.99" + + @property + def context(self): + return { + "SOLUTION_NAME": "Maintaining Personalized Experiences with Machine Learning", + "SOLUTION_ID": self.id, + "SOLUTION_VERSION": self.version, + } + + +@pytest.fixture +def solution(): + return Solution() + + +@pytest.fixture(scope="session", autouse=True) +def solution_env(): + os.environ[ + "SNS_TOPIC_ARN" + ] = f"arn:aws:sns:us-east-1:{'1'*12}:some-personalize-notification-topic" + os.environ[ + "STATE_MACHINE_ARN" + ] = f"arn:aws:states:us-east-1:{'1'*12}:stateMachine:personalize-workflow" + yield + + +@pytest.fixture +def cdk_entrypoint(): + """This otherwise would not be importable (it's not in a package, and is intended to be a script)""" + sys.path.append(str((Path(__file__).parent.parent / "infrastructure").absolute())) + yield + + +@pytest.fixture +def personalize_stubber(): + personalize_client = get_service_client("personalize") + with Stubber(personalize_client) as stubber: + yield stubber + + +def mock_lambda_init( + self, # NOSONAR (python:S107) - allow large number of method parameters + scope: Construct, + id: str, + *, + code: Code, + handler: str, + runtime: Runtime, + **kwargs, +) -> None: + # overriding the code will prevent building lambda functions + # override the runtime list for now, as well, to match above + props = FunctionProps( + code=Code.from_inline("return"), + handler=handler, + runtime=Runtime.PYTHON_3_7, + **kwargs, + ) + jsii.create(Function, self, [scope, id, props]) + + +def mock_layer_init(self, scope: Construct, id: str, *, code: Code, **kwargs) -> None: + # overriding the layers will prevent building lambda layers + # override the runtime list for now, as well, to match above + with TemporaryDirectory() as tmpdirname: + kwargs["code"] = Code.from_asset(path=tmpdirname) + kwargs["compatible_runtimes"] = [Runtime.PYTHON_3_7] + props = LayerVersionProps(**kwargs) + jsii.create(LayerVersion, self, [scope, id, props]) + + +@pytest.fixture(autouse=True) +def cdk_lambda_mocks(mocker, request): + """Using this session mocker means we cannot assert anything about functions or layer versions of this stack""" + if "no_cdk_lambda_mock" in request.keywords: + yield + else: + mocker.patch("aws_cdk.aws_lambda.Function.__init__", mock_lambda_init) + mocker.patch("aws_cdk.aws_lambda.LayerVersion.__init__", mock_layer_init) + yield + + +@pytest.fixture +def configuration_path(): + return Path(__file__).parent / "fixtures" / "config" / "sample_config.json" diff --git a/source/tests/fixtures/config/sample_config.json b/source/tests/fixtures/config/sample_config.json new file mode 100644 index 0000000..67209e7 --- /dev/null +++ b/source/tests/fixtures/config/sample_config.json @@ -0,0 +1,174 @@ +{ + "datasetGroup": { + "serviceConfig": { + "name": "unit_test_new_datasetgroup" + }, + "workflowConfig": { + "schedules": { + "import": "cron(0 */6 * * ? *)" + } + } + }, + "datasets": { + "users": { + "dataset": { + "serviceConfig": { + "name": "unit_test_only_users" + } + }, + "schema": { + "serviceConfig": { + "name": "unit_test_only_users_schema", + "schema": { + "type": "record", + "name": "users", + "namespace": "com.amazonaws.personalize.schema", + "fields": [ + { + "name": "USER_ID", + "type": "string" + }, + { + "name": "AGE", + "type": "int" + }, + { + "name": "GENDER", + "type": "string", + "categorical": true + } + ] + } + } + } + }, + "interactions": { + "dataset": { + "serviceConfig": { + "name": "unit_test_only_interactions" + } + }, + "schema": { + "serviceConfig": { + "name": "unit_test_only_interactions_schema", + "schema": { + "type": "record", + "name": "interactions", + "namespace": "com.amazonaws.personalize.schema", + "fields": [ + { + "name": "ITEM_ID", + "type": "string" + }, + { + "name": "USER_ID", + "type": "string" + }, + { + "name": "TIMESTAMP", + "type": "long" + }, + { + "name": "EVENT_TYPE", + "type": "string" + }, + { + "name": "EVENT_VALUE", + "type": "float" + } + ] + } + } + } + } + }, + "solutions": [ + { + "serviceConfig": { + "name": "unit_test_sims_new", + "recipeArn": "arn:aws:personalize:::recipe/aws-sims" + }, + "workflowConfig": { + "schedules": { + "full": "cron(0 0 ? * 1 *)" + } + } + }, + { + "serviceConfig": { + "name": "unit_test_popularity_count_new", + "recipeArn": "arn:aws:personalize:::recipe/aws-popularity-count" + }, + "workflowConfig": { + "schedules": { + "full": "cron(0 1 ? * 1 *)" + } + } + }, + { + "serviceConfig": { + "name": "unit_test_personalized_ranking_new", + "recipeArn": "arn:aws:personalize:::recipe/aws-user-personalization" + }, + "workflowConfig": { + "schedules": { + "full": "cron(0 2 ? * 1 *)" + } + }, + "campaigns": [ + { + "serviceConfig": { + "name": "unit_test_personalized_ranking_new_campaign", + "minProvisionedTPS": 1 + } + } + ] + }, + { + "serviceConfig": { + "name": "unit_test_personalized_ranking_new_2", + "recipeArn": "arn:aws:personalize:::recipe/aws-user-personalization" + }, + "workflowConfig": { + "schedules": { + "full": "cron(0 2 ? * 1 *)" + } + }, + "campaigns": [ + { + "serviceConfig": { + "name": "unit_test_personalized_ranking_2_campaign", + "minProvisionedTPS": 1 + } + } + ], + "batchInferenceJobs": [ + { + "serviceConfig": {}, + "workflowConfig": { + "schedule": "cron(0 3 * * ? *)" + } + } + ] + } + ], + "eventTracker": { + "serviceConfig": { + "name": "unit_test_new_event_tracker" + } + }, + "filters": [ + { + "serviceConfig": { + "name": "clicked-or-streamed-2", + "filterExpression": "INCLUDE ItemID WHERE Interactions.EVENT_TYPE in (\"click\", \"stream\")" + } + }, + { + "serviceConfig": { + "name": "interacted-2", + "filterExpression": "INCLUDE ItemID WHERE Interactions.EVENT_TYPE in (\"*\")" + } + } + ] +} \ No newline at end of file diff --git a/source/tests/fixtures/config/step_1.json b/source/tests/fixtures/config/step_1.json new file mode 100644 index 0000000..c59d237 --- /dev/null +++ b/source/tests/fixtures/config/step_1.json @@ -0,0 +1,5 @@ +{ + "datasetGroup": { + "name": "unit_test_only_datasetgroup" + } +} \ No newline at end of file diff --git a/source/tests/fixtures/config/step_2.json b/source/tests/fixtures/config/step_2.json new file mode 100644 index 0000000..e9b73d0 --- /dev/null +++ b/source/tests/fixtures/config/step_2.json @@ -0,0 +1,124 @@ +{ + "schedule": { + "ScheduleExpression": "rate(1 minute)" + }, + "datasetGroup": { + "name": "unit_test_only_datasetgroup" + }, + "datasets": { + "users": { + "dataset": { + "name": "unit_test_only_users" + }, + "schema": { + "name": "unit_test_only_users_schema", + "schema": { + "type": "record", + "name": "users", + "namespace": "com.amazonaws.personalize.schema", + "fields": [ + { + "name": "USER_ID", + "type": "string" + }, + { + "name": "AGE", + "type": "int" + }, + { + "name": "GENDER", + "type": "string", + "categorical": true + } + ] + } + } + }, + "interactions": { + "dataset": { + "name": "unit_test_only_interactions" + }, + "schema": { + "name": "unit_test_only_interactions_schema", + "schema": { + "type": "record", + "name": "interactions", + "namespace": "com.amazonaws.personalize.schema", + "fields": [ + { + "name": "ITEM_ID", + "type": "string" + }, + { + "name": "USER_ID", + "type": "string" + }, + { + "name": "TIMESTAMP", + "type": "long" + }, + { + "name": "EVENT_TYPE", + "type": "string" + }, + { + "name": "EVENT_VALUE", + "type": "float" + } + ] + } + } + } + }, + "eventTracker": { + "name": "unit_test_event_tracker" + }, + "filters": [ + { + "name": "clicked-or-streamed", + "filterExpression": "INCLUDE ItemID WHERE Interactions.EVENT_TYPE in (\"click\", \"stream\")" + }, + { + "name": "interacted", + "filterExpression": "INCLUDE ItemID WHERE Interactions.EVENT_TYPE in (\"*\")" + } + ], + "solutions": [ + { + "solution": { + "name": "unit_test_sims", + "recipeArn": "arn:aws:personalize:::recipe/aws-sims" + } + }, + { + "solution": { + "name": "unit_test_popularity_count", + "recipeArn": "arn:aws:personalize:::recipe/aws-popularity-count" + }, + "solutionVersions": [ + { + "solutionVersion": {} + } + ] + }, + { + "solution": { + "name": "unit_test_personalized_ranking", + "recipeArn": "arn:aws:personalize:::recipe/aws-personalized-ranking" + }, + "solutionVersions": [ + { + "solutionVersion": {}, + "campaigns": [ + { + "campaign": { + "name": "unit_test_personalized_ranking_campaign", + "minProvisionedTPS": 1 + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/source/tests/fixtures/config/step_4.json b/source/tests/fixtures/config/step_4.json new file mode 100644 index 0000000..e7582dd --- /dev/null +++ b/source/tests/fixtures/config/step_4.json @@ -0,0 +1,130 @@ +{ + "schedule": { + "ScheduleExpression": "rate(6 hours)" + }, + "datasetGroup": { + "name": "unit_test_only_datasetgroup" + }, + "datasets": { + "users": { + "dataset": { + "name": "unit_test_only_users" + }, + "schema": { + "name": "unit_test_only_users_schema", + "schema": { + "type": "record", + "name": "users", + "namespace": "com.amazonaws.personalize.schema", + "fields": [ + { + "name": "USER_ID", + "type": "string" + }, + { + "name": "AGE", + "type": "int" + }, + { + "name": "GENDER", + "type": "string", + "categorical": true + } + ] + } + } + }, + "interactions": { + "dataset": { + "name": "unit_test_only_interactions" + }, + "schema": { + "name": "unit_test_only_interactions_schema", + "schema": { + "type": "record", + "name": "interactions", + "namespace": "com.amazonaws.personalize.schema", + "fields": [ + { + "name": "ITEM_ID", + "type": "string" + }, + { + "name": "USER_ID", + "type": "string" + }, + { + "name": "TIMESTAMP", + "type": "long" + }, + { + "name": "EVENT_TYPE", + "type": "string" + }, + { + "name": "EVENT_VALUE", + "type": "float" + } + ] + } + } + } + }, + "eventTracker": { + "name": "unit_test_event_tracker" + }, + "filters": [ + { + "name": "clicked-or-streamed", + "filterExpression": "INCLUDE ItemID WHERE Interactions.EVENT_TYPE in (\"click\", \"stream\")" + }, + { + "name": "interacted", + "filterExpression": "INCLUDE ItemID WHERE Interactions.EVENT_TYPE in (\"*\")" + } + ], + "solutions": [ + { + "solution": { + "name": "unit_test_sims", + "recipeArn": "arn:aws:personalize:::recipe/aws-sims" + } + }, + { + "solution": { + "name": "unit_test_popularity_count", + "recipeArn": "arn:aws:personalize:::recipe/aws-popularity-count" + }, + "solutionVersions": [ + { + "solutionVersion": {} + } + ] + }, + { + "solution": { + "name": "unit_test_personalized_ranking", + "recipeArn": "arn:aws:personalize:::recipe/aws-personalized-ranking" + }, + "solutionVersions": [ + { + "solutionVersion": {}, + "campaigns": [ + { + "campaign": { + "name": "unit_test_personalized_ranking_campaign", + "minProvisionedTPS": 1 + } + } + ], + "batchInferenceJobs": [ + { + "batchInferenceJob": { + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/source/tests/fixtures/config/users.csv b/source/tests/fixtures/config/users.csv new file mode 100644 index 0000000..7246776 --- /dev/null +++ b/source/tests/fixtures/config/users.csv @@ -0,0 +1,25 @@ +0,71,F +1,67,M +2,25,F +3,70,F +4,28,F +5,34,F +6,66,F +7,74,F +8,79,F +9,57,M +10,58,M +11,18,M +12,88,M +13,73,F +14,77,F +15,23,F +16,85,M +17,31,M +18,48,M +19,44,M +20,24,F +21,63,M +22,66,F +23,21,F +24,81,M diff --git a/source/tests/test_deploy.py b/source/tests/test_deploy.py new file mode 100644 index 0000000..7d5b678 --- /dev/null +++ b/source/tests/test_deploy.py @@ -0,0 +1,42 @@ +# ###################################################################################################################### +# 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 sys +from pathlib import Path + +import pytest + + +@pytest.fixture +def cdk_entrypoint(): + """This otherwise would not be importable (it's not in a package, and is intended to be a script)""" + sys.path.append(str((Path(__file__).parent.parent / "infrastructure").absolute())) + yield + + +def test_deploy(solution, cdk_entrypoint): + from deploy import build_app + + extra_context = "EXTRA_CONTEXT" + source_bucket = "SOURCE_BUCKET" + synth = build_app({extra_context: extra_context, "BUCKET_NAME": source_bucket}) + stack = synth.get_stack("PersonalizeStack") + assert solution.id in stack.template["Description"] + assert ( + source_bucket == stack.template["Mappings"]["SourceCode"]["General"]["S3Bucket"] + ) + assert solution.id == stack.template["Mappings"]["Solution"]["Data"]["ID"] + assert ( + "Yes" + == stack.template["Mappings"]["Solution"]["Data"]["SendAnonymousUsageData"] + ) diff --git a/source/tests/test_personalize_stack.py b/source/tests/test_personalize_stack.py new file mode 100644 index 0000000..17002ff --- /dev/null +++ b/source/tests/test_personalize_stack.py @@ -0,0 +1,30 @@ +# ###################################################################################################################### +# 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 aws_cdk.core as cdk + +from infrastructure.personalize.stack import PersonalizeStack + + +def test_personalize_stack_email(solution): + app = cdk.App(context=solution.context) + PersonalizeStack( + app, + "PersonalizeStack", + description="meta-stack", + template_filename="maintaining-personalized-experiences-with-machine-learning.template", + ) + synth = app.synth() + + # ensure the email parameter is present + assert synth.get_stack("PersonalizeStack").template["Parameters"]["Email"] diff --git a/source/tests/test_resources.py b/source/tests/test_resources.py new file mode 100644 index 0000000..e77860e --- /dev/null +++ b/source/tests/test_resources.py @@ -0,0 +1,59 @@ +# ###################################################################################################################### +# 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 pytest + +from shared.resource import ( + DatasetGroup, + Schema, + Dataset, + DatasetImportJob, + Solution, + SolutionVersion, + Campaign, + EventTracker, +) + + +@pytest.mark.parametrize( + "klass,camel,dash,snake", + [ + (DatasetGroup, "datasetGroup", "dataset-group", "dataset_group"), + (Schema, "schema", "schema", "schema"), + (Dataset, "dataset", "dataset", "dataset"), + ( + DatasetImportJob, + "datasetImportJob", + "dataset-import-job", + "dataset_import_job", + ), + (Solution, "solution", "solution", "solution"), + (SolutionVersion, "solutionVersion", "solution-version", "solution_version"), + (Campaign, "campaign", "campaign", "campaign"), + (EventTracker, "eventTracker", "event-tracker", "event_tracker"), + ], + ids=[ + "DatasetGroup", + "Schema", + "Dataset", + "DatasetImportJob", + "Solution", + "SolutionVersion", + "Campaign", + "EventTracker", + ], +) +def test_resource_naming(klass, camel, dash, snake): + assert klass().name.camel == camel + assert klass().name.dash == dash + assert klass().name.snake == snake diff --git a/source/tests/test_scheduler.py b/source/tests/test_scheduler.py new file mode 100644 index 0000000..6bcb6ee --- /dev/null +++ b/source/tests/test_scheduler.py @@ -0,0 +1,244 @@ +# ###################################################################################################################### +# 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 json +import os + +import boto3 +import pytest +from moto.core import ACCOUNT_ID +from moto.dynamodb2 import mock_dynamodb2 +from moto.stepfunctions import mock_stepfunctions + +from aws_lambda.scheduler.handler import ( + create_schedule, + read_schedule, + update_schedule, + delete_schedule, +) +from shared.scheduler.base import Scheduler +from shared.scheduler.schedule import Schedule, ScheduleError +from shared.scheduler.task import Task + + +@pytest.fixture +def scheduler_stepfunctions_target_arn(): + stepfunction_name = "personalizestack-personalize-target" + stepfunction_arn = ( + f"arn:aws:states:us-east-1:{ACCOUNT_ID}:stateMachine:{stepfunction_name}" + ) + return stepfunction_arn + + +@pytest.fixture +def scheduler_stepfunctions_scheduler_arn(): + stepfunction_name = "personalizestack-personalize-scheduler" + stepfunction_arn = ( + f"arn:aws:states:us-east-1:{ACCOUNT_ID}:stateMachine:{stepfunction_name}" + ) + return stepfunction_arn + + +@pytest.fixture +def scheduler_stepfunctions( + scheduler_stepfunctions_target_arn, scheduler_stepfunctions_scheduler_arn +): + with mock_stepfunctions(): + sfn = boto3.client("stepfunctions") + definition = json.dumps( + { + "StartAt": "FirstState", + "States": { + "Type": "Task", + "Resource": f"arn:aws:lambda:us-east-1:{ACCOUNT_ID}:function:FUNCTION_NAME", + "End": True, + }, + } + ) + sfn.create_state_machine( + name=scheduler_stepfunctions_target_arn.split(":")[-1], + definition=definition, + roleArn=f"arn:aws:iam::{ACCOUNT_ID}:role/sf_role", + ) + sfn.create_state_machine( + name=scheduler_stepfunctions_scheduler_arn.split(":")[-1], + definition=definition, + roleArn=f"arn:aws:iam::{ACCOUNT_ID}:role/sf_role", + ) + yield sfn, scheduler_stepfunctions_target_arn, scheduler_stepfunctions_scheduler_arn + + +@pytest.fixture +def scheduler_table(): + scheduler_table_name = "scheduler" + os.environ["DDB_SCHEDULES_TABLE"] = scheduler_table_name + + with mock_dynamodb2(): + ddb = boto3.resource("dynamodb") + ddb.create_table( + TableName=scheduler_table_name, + KeySchema=[ + {"AttributeName": "name", "KeyType": "HASH"}, + { + "AttributeName": "version", + "KeyType": "RANGE", + }, + ], + AttributeDefinitions=[ + {"AttributeName": "name", "AttributeType": "S"}, + {"AttributeName": "version", "AttributeType": "S"}, + ], + ) + + yield ddb, scheduler_table_name + + +@pytest.fixture +def task(scheduler_stepfunctions_target_arn): + return Task( + name="test", + schedule="cron(* * * * ? *)", + state_machine={"arn": scheduler_stepfunctions_target_arn, "input": {}}, + ) + + +@pytest.fixture +def scheduler(scheduler_table, scheduler_stepfunctions, mocker): + _, scheduler_table_name = scheduler_table + sfn_cli, _, sfn_arn = scheduler_stepfunctions + + _scheduler = Scheduler() + _scheduler.sfn_cli = sfn_cli + _scheduler.stepfunction = sfn_arn + mocker.patch("aws_lambda.scheduler.handler.scheduler", _scheduler) + + yield _scheduler + + +def test_create(scheduler, task): + scheduler.create(task) + scheduled = scheduler.read(task.name) + assert scheduled == task + + +def test_read(scheduler, task): + scheduler.create(task) + scheduler.update(task) + + scheduled = scheduler.read(task) + assert scheduled.latest == 1 + assert scheduled.version == "v0" + + +def test_delete(scheduler, task): + scheduler.create(task) + scheduler.update(task) + scheduler.delete(task) + + assert not scheduler.read(task) # the updated item should no longer be present + + +def test_list(scheduler, task): + # create two tasks, then list them + scheduler.create(task) + scheduler.update(task) + task.name = "test1" + task.next_task_id = task.get_next_task_id() + scheduler.create(task) + scheduler.update(task) + + schedules = [s for s in scheduler.list()] + assert len(schedules) == 2 + assert "test" in schedules + assert "test1" in schedules + + +def test_scheduler_create_handler(scheduler, scheduler_stepfunctions_target_arn): + create_schedule( + { + "name": "test", + "schedule": "cron(* * * * ? *)", + "state_machine": { + "arn": scheduler_stepfunctions_target_arn, + "input": {}, + }, + }, + None, + ) + + +def test_scheduler_update_handler(task, scheduler, scheduler_stepfunctions_target_arn): + scheduler.create(task) + assert scheduler.read(task).schedule == task.schedule + + new_schedule = Schedule("cron(10 * * * ? *)") + update_schedule( + { + "name": "test", + "schedule": new_schedule.expression, + "state_machine": { + "arn": scheduler_stepfunctions_target_arn, + "input": {}, + }, + }, + None, + ) + assert scheduler.read(task).schedule == new_schedule + assert scheduler.read(task).latest == 2 + + +def test_read_schedule_handler(task, scheduler): + scheduler.create(task) + result = read_schedule( + { + "name": "test", + }, + None, + ) + + assert result.get("name") == task.name + assert result.get("schedule") == task.schedule.expression + + +def test_delete_schedule_handler(task, scheduler): + scheduler.create(task) + + assert scheduler.read(task.name) + delete_schedule( + { + "name": "test", + }, + None, + ) + assert not scheduler.read(task.name) + + +def test_delete_as_create(scheduler): + task = Task("testing", schedule="delete") + + scheduler.create(task) + assert not scheduler.read(task.name) + + +@pytest.mark.parametrize( + "expression", + [ + "cron(0 12 * * ? * *)", # too many fields + "cron(0 12 * * ?)", # too few fields + "cron(5,35 14 * * * *)", # both day of week and day of month specified + "cron(5,35 14 * * ? 1888)", # year too early + "not-cron", # not a cron expression + ], +) +def test_configuration_cron_invalid(expression): + with pytest.raises(ScheduleError): + Schedule(expression)