├── .eslintrc.js ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── .projen ├── deps.json ├── files.json ├── jest-snapshot-resolver.js └── tasks.json ├── .projenrc.ts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE.txt ├── README.md ├── SECURITY.md ├── cdk.json ├── deployment ├── build-s3-dist.sh ├── cdk-solution-helper │ ├── README.md │ ├── asset-packager │ │ ├── __tests__ │ │ │ ├── asset-packager.test.ts │ │ │ └── handler.test.ts │ │ ├── asset-packager.ts │ │ └── index.ts │ ├── jest.config.ts │ ├── package-lock.json │ └── package.json └── run-unit-tests.sh ├── global.d.ts ├── jest.config.json ├── package-lock.json ├── package.json ├── projenrc └── PULL_REQUEST_TEMPLATE.md ├── source ├── app │ ├── .coveragerc │ ├── .gitattributes │ ├── .gitignore │ ├── .projen │ │ ├── deps.json │ │ ├── files.json │ │ └── tasks.json │ ├── README.md │ ├── instance_scheduler │ │ ├── __init__.py │ │ ├── boto_retry │ │ │ └── __init__.py │ │ ├── configuration │ │ │ ├── __init__.py │ │ │ ├── global_config_builder.py │ │ │ ├── instance_schedule.py │ │ │ ├── running_period.py │ │ │ ├── running_period_dict_element.py │ │ │ ├── scheduling_context.py │ │ │ ├── ssm.py │ │ │ └── time_utils.py │ │ ├── cron │ │ │ ├── __init__.py │ │ │ ├── asg.py │ │ │ ├── cron_recurrence_expression.py │ │ │ ├── cron_to_running_period.py │ │ │ ├── expression.py │ │ │ ├── parser.py │ │ │ └── validator.py │ │ ├── handler │ │ │ ├── __init__.py │ │ │ ├── asg.py │ │ │ ├── asg_orchestrator.py │ │ │ ├── base.py │ │ │ ├── cfn_schedule.py │ │ │ ├── cli │ │ │ │ ├── __init__.py │ │ │ │ ├── cli_request_handler.py │ │ │ │ └── schedule_usage.py │ │ │ ├── config_resource.py │ │ │ ├── environments │ │ │ │ ├── asg_env.py │ │ │ │ ├── asg_orch_env.py │ │ │ │ ├── main_lambda_environment.py │ │ │ │ ├── metrics_uuid_environment.py │ │ │ │ ├── orchestrator_environment.py │ │ │ │ ├── remote_registration_environment.py │ │ │ │ └── scheduling_request_environment.py │ │ │ ├── metrics_uuid_custom_resource.py │ │ │ ├── remote_registration_custom_resource.py │ │ │ ├── schedule_update.py │ │ │ ├── scheduling_orchestrator.py │ │ │ ├── scheduling_request.py │ │ │ ├── setup_demo_data.py │ │ │ └── spoke_registration.py │ │ ├── main.py │ │ ├── maint_win │ │ │ ├── __init__.py │ │ │ ├── maintenance_window_context.py │ │ │ └── ssm_mw_client.py │ │ ├── model │ │ │ ├── __init__.py │ │ │ ├── ddb_config_item.py │ │ │ ├── ddb_item_utils.py │ │ │ ├── maint_win.py │ │ │ ├── period_definition.py │ │ │ ├── period_identifier.py │ │ │ ├── schedule_definition.py │ │ │ └── store │ │ │ │ ├── __init__.py │ │ │ │ ├── ddb_config_item_store.py │ │ │ │ ├── ddb_transact_write.py │ │ │ │ ├── dynamo_client.py │ │ │ │ ├── dynamo_mw_store.py │ │ │ │ ├── dynamo_period_definition_store.py │ │ │ │ ├── dynamo_schedule_definition_store.py │ │ │ │ ├── in_memory_mw_store.py │ │ │ │ ├── in_memory_period_definition_store.py │ │ │ │ ├── in_memory_schedule_definition_store.py │ │ │ │ ├── maint_win_store.py │ │ │ │ ├── mw_store.py │ │ │ │ ├── period_definition_store.py │ │ │ │ └── schedule_definition_store.py │ │ ├── ops_metrics │ │ │ ├── __init__.py │ │ │ ├── anonymous_metric_wrapper.py │ │ │ ├── metric_type │ │ │ │ ├── asg_count_metric.py │ │ │ │ ├── cli_request_metric.py │ │ │ │ ├── deployment_description_metric.py │ │ │ │ ├── insights_metric.py │ │ │ │ ├── instance_count_metric.py │ │ │ │ ├── ops_metric.py │ │ │ │ └── scheduling_action_metric.py │ │ │ └── metrics.py │ │ ├── ops_monitoring │ │ │ ├── __init__.py │ │ │ ├── cw_ops_insights.py │ │ │ └── instance_counts.py │ │ ├── schedulers │ │ │ ├── __init__.py │ │ │ ├── instance.py │ │ │ ├── instance_scheduler.py │ │ │ ├── instance_states.py │ │ │ ├── scheduling_decision.py │ │ │ ├── scheduling_result.py │ │ │ └── states.py │ │ ├── service │ │ │ ├── __init__.py │ │ │ ├── abstract_instance.py │ │ │ ├── asg.py │ │ │ ├── base.py │ │ │ ├── ec2.py │ │ │ ├── ec2_instance.py │ │ │ ├── rds.py │ │ │ └── rds_instance.py │ │ └── util │ │ │ ├── __init__.py │ │ │ ├── app_env_utils.py │ │ │ ├── batch.py │ │ │ ├── custom_encoder.py │ │ │ ├── custom_resource.py │ │ │ ├── display_helper.py │ │ │ ├── dynamodb_utils.py │ │ │ ├── logger.py │ │ │ ├── pagination.py │ │ │ ├── scheduling_target.py │ │ │ ├── session_manager.py │ │ │ ├── sns_handler.py │ │ │ ├── time.py │ │ │ └── validation.py │ ├── mypy.ini │ ├── poetry.lock │ ├── pyproject.toml │ ├── tests │ │ ├── __init__.py │ │ ├── boto_retry │ │ │ ├── __init__.py │ │ │ └── test_boto_retry_init.py │ │ ├── cli │ │ │ ├── __init__.py │ │ │ ├── test_cli_request_handler.py │ │ │ └── test_schedule_usage.py │ │ ├── configuration │ │ │ ├── __init__.py │ │ │ ├── test_configuration.py │ │ │ ├── test_running_period.py │ │ │ └── test_time_utils.py │ │ ├── conftest.py │ │ ├── context.py │ │ ├── cron │ │ │ ├── __init__.py │ │ │ ├── test_asg.py │ │ │ ├── test_cron_to_running_period.py │ │ │ ├── test_monthdays_parser.py │ │ │ ├── test_months_parser.py │ │ │ └── test_weekdays_parser.py │ │ ├── handler │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── test_asg.py │ │ │ ├── test_asg_orchestrator.py │ │ │ ├── test_cfn_schedule_handler.py │ │ │ ├── test_metrics_uuid_custom_resource.py │ │ │ ├── test_remote_registration_custom_resource.py │ │ │ ├── test_schedule_update.py │ │ │ ├── test_scheduler_setup_handler.py │ │ │ ├── test_scheduling_orchestration_handler.py │ │ │ └── test_spoke_registration_handler.py │ │ ├── integration │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── helpers │ │ │ │ ├── __init__.py │ │ │ │ ├── asg_helpers.py │ │ │ │ ├── boto_client_helpers.py │ │ │ │ ├── ec2_helpers.py │ │ │ │ ├── rds_helpers.py │ │ │ │ ├── run_handler.py │ │ │ │ ├── schedule_helpers.py │ │ │ │ └── scheduling_context_builder.py │ │ │ ├── ops_metrics │ │ │ │ ├── __init__.py │ │ │ │ ├── conftest.py │ │ │ │ ├── test_cli_metrics.py │ │ │ │ ├── test_deployment_description_metrics.py │ │ │ │ ├── test_instance_count_metrics.py │ │ │ │ ├── test_metrics_handler.py │ │ │ │ ├── test_ops_insights_metrics.py │ │ │ │ └── test_scheduling_action_metrics.py │ │ │ ├── test_1_sided_schedules.py │ │ │ ├── test_asg.py │ │ │ ├── test_basic_ec2_scheduling.py │ │ │ ├── test_basic_rds_scheduling.py │ │ │ ├── test_basic_timezone_handling.py │ │ │ ├── test_create_rds_snapshot_flag.py │ │ │ ├── test_cross_account_scheduling.py │ │ │ ├── test_ec2_instance_tagging.py │ │ │ ├── test_ec2_schedule_retry.py │ │ │ ├── test_lambda_schedule_encoding_limits.py │ │ │ ├── test_maint_window_scheduling.py │ │ │ ├── test_multi_period_schedules.py │ │ │ ├── test_nth_weekday_scheduling.py │ │ │ ├── test_op_metrics_level.py │ │ │ ├── test_rds_cluster_instance.py │ │ │ ├── test_rds_cluster_scheduling.py │ │ │ ├── test_resize.py │ │ │ ├── test_retain_running_flag.py │ │ │ └── test_stop_new_instances_flag.py │ │ ├── logger.py │ │ ├── maint_win │ │ │ ├── __init__.py │ │ │ ├── test_maintenance_window_context.py │ │ │ └── test_ssm_mw_client.py │ │ ├── model │ │ │ ├── __init__.py │ │ │ ├── store │ │ │ │ ├── __init__.py │ │ │ │ ├── test_ddb_config_item_store.py │ │ │ │ ├── test_dynamo_period_definition_store.py │ │ │ │ ├── test_dynamo_schedule_definition_store.py │ │ │ │ ├── test_in_memory_period_definition_store.py │ │ │ │ ├── test_in_memory_schedule_definition_store.py │ │ │ │ ├── test_maint_win_store.py │ │ │ │ ├── test_mw_store.py │ │ │ │ ├── test_period_definition_store.py │ │ │ │ └── test_schedule_definition_store.py │ │ │ ├── test_ddb_item_utils.py │ │ │ ├── test_maint_win.py │ │ │ ├── test_period_identifier.py │ │ │ ├── test_running_period_definition.py │ │ │ └── test_schedule_definition.py │ │ ├── ops_monitoring │ │ │ └── test_instance_counts.py │ │ ├── schedulers │ │ │ ├── __init__.py │ │ │ └── test_instance_scheduler.py │ │ ├── service │ │ │ ├── __init__.py │ │ │ ├── test_asg_service.py │ │ │ ├── test_asg_tag.py │ │ │ ├── test_ec2_service.py │ │ │ └── test_rds_service.py │ │ ├── test_enforce_headers.py │ │ ├── test_init.py │ │ ├── test_main.py │ │ ├── test_utils │ │ │ ├── any_nonempty_string.py │ │ │ ├── mock_asg_environment.py │ │ │ ├── mock_asg_orchestrator_environment.py │ │ │ ├── mock_main_lambda_env.py │ │ │ ├── mock_metrics_environment.py │ │ │ ├── mock_metrics_uuid_environment.py │ │ │ ├── mock_orchestrator_environment.py │ │ │ ├── mock_scheduling_request_environment.py │ │ │ ├── testsuite_env.py │ │ │ └── unordered_list.py │ │ └── util │ │ │ ├── __init__.py │ │ │ ├── test_app_env_utils.py │ │ │ ├── test_batch.py │ │ │ ├── test_display_helper.py │ │ │ ├── test_init.py │ │ │ ├── test_session_manager.py │ │ │ ├── test_sns_handler.py │ │ │ ├── test_time.py │ │ │ └── test_validation.py │ └── tox.ini ├── cli │ ├── .coveragerc │ ├── .gitattributes │ ├── .gitignore │ ├── .projen │ │ ├── deps.json │ │ ├── files.json │ │ └── tasks.json │ ├── README.md │ ├── instance_scheduler_cli │ │ ├── __init__.py │ │ ├── __main__.py │ │ └── scheduler_cli.py │ ├── mypy.ini │ ├── poetry.lock │ ├── pyproject.toml │ ├── tests │ │ ├── conftest.py │ │ ├── test_cli.py │ │ ├── test_enforce_headers.py │ │ ├── test_service_client.py │ │ └── test_version.py │ └── tox.ini ├── instance-scheduler.ts └── instance-scheduler │ ├── lib │ ├── anonymized-metrics-environment.ts │ ├── app-registry.ts │ ├── asg-scheduler.ts │ ├── cdk-context.ts │ ├── cfn-nag.ts │ ├── cfn.ts │ ├── core-scheduler.ts │ ├── dashboard │ │ ├── metrics.ts │ │ ├── ops-insights-dashboard.ts │ │ └── widgets.ts │ ├── iam │ │ ├── asg-scheduling-permissions-policy.ts │ │ ├── asg-scheduling-role.ts │ │ ├── ec2-kms-permissions-policy.ts │ │ ├── roles.ts │ │ ├── scheduler-role.ts │ │ └── scheduling-permissions-policy.ts │ ├── instance-scheduler-stack.ts │ ├── lambda-functions │ │ ├── asg-handler.ts │ │ ├── asg-orchestrator.ts │ │ ├── function-factory.ts │ │ ├── main.ts │ │ ├── metrics-uuid-generator.ts │ │ ├── remote-registration.ts │ │ ├── schedule-update-handler.ts │ │ ├── scheduling-orchestrator.ts │ │ ├── scheduling-request-handler.ts │ │ └── spoke-registration.ts │ ├── remote-stack.ts │ ├── runbooks │ │ └── spoke-deregistration.ts │ ├── scheduling-interval-mappings.ts │ └── time-zones.ts │ └── tests │ ├── __snapshots__ │ ├── instance-scheduler-remote-stack.test.ts.snap │ └── instance-scheduler-stack.test.ts.snap │ ├── init-jest-extended.ts │ ├── instance-scheduler-remote-stack.test.ts │ ├── instance-scheduler-stack-factory.ts │ ├── instance-scheduler-stack.test.ts │ ├── lib │ ├── asg-scheduler.test.ts │ ├── cfn-nag.test.ts │ ├── cfn.test.ts │ ├── core-scheduler.test.ts │ ├── lambda-functions │ │ ├── asg-handler.test.ts │ │ ├── scheduling-orchestrator.test.ts │ │ ├── scheduling-request-handler.test.ts │ │ └── spoke-registration.test.ts │ ├── ops-insights-dashboard.test.ts │ ├── runbooks │ │ └── spoke-deregistration.test.ts │ └── spoke-registration.test.ts │ ├── test_function │ ├── __init__.py │ └── test_function.py │ └── test_utils │ └── stack-factories.ts ├── tsconfig.json └── update-all-dependencies.sh /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | module.exports = { 4 | env: { 5 | jest: true, 6 | node: true, 7 | es2021: true, 8 | }, 9 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 10 | ignorePatterns: [".eslintrc.js", "coverage/", "deployment/coverage-reports/", "build/", "internal/"], 11 | overrides: [], 12 | parserOptions: { 13 | ecmaVersion: "latest", 14 | project: "./tsconfig.json", 15 | sourceType: "module", 16 | }, 17 | plugins: ["header", "import"], 18 | rules: { 19 | "header/header": [ 20 | "error", 21 | "line", 22 | [" Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.", " SPDX-License-Identifier: Apache-2.0"], 23 | ], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.ts and run "npx projen". 2 | 3 | * text=auto eol=lf 4 | *.snap linguist-generated 5 | /.gitattributes linguist-generated 6 | /.github/pull_request_template.md linguist-generated 7 | /.gitignore linguist-generated 8 | /.projen/** linguist-generated 9 | /.projen/deps.json linguist-generated 10 | /.projen/files.json linguist-generated 11 | /.projen/jest-snapshot-resolver.js linguist-generated 12 | /.projen/tasks.json linguist-generated 13 | /cdk.json linguist-generated 14 | /jest.config.json linguist-generated 15 | /LICENSE linguist-generated 16 | /package-lock.json linguist-generated 17 | /package.json linguist-generated 18 | /solution-manifest.yaml linguist-generated 19 | /source/pipeline/jest.config.json linguist-generated 20 | /tsconfig.json linguist-generated -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | 17 | **Expected behavior** 18 | 19 | 20 | 21 | **Please complete the following information about the solution:** 22 | 23 | - [ ] Version: [e.g. v1.0.0] 24 | 25 | To get the version of the solution, you can look at the description of the created CloudFormation stack. For example, 26 | _"(SO0030) instance-scheduler-on-aws v1.5.1"_. You can also find the version from 27 | [releases](https://github.com/aws-solutions/instance-scheduler-on-aws/releases) 28 | 29 | - [ ] Region: [e.g. us-east-1] 30 | - [ ] Was the solution modified from the version published on this repository? 31 | - [ ] If the answer to the previous question was yes, are the changes available on GitHub? 32 | - [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for 33 | the sevices this solution uses? 34 | - [ ] Were there any errors in the CloudWatch Logs? 35 | [Troubleshooting](https://docs.aws.amazon.com/solutions/latest/instance-scheduler-on-aws/troubleshooting.html) 36 | 37 | **Screenshots** 38 | If applicable, add screenshots to help explain your problem (please **DO NOT include sensitive information**). 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this solution 4 | title: "" 5 | labels: feature-request, enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | 11 | 12 | 13 | **Describe the feature you'd like** 14 | 15 | 16 | 17 | **Additional context** 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | _Issue #, if available:_ 2 | 3 | _Description of changes:_ 4 | 5 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the 6 | terms of your choice. 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.ts and run "npx projen". 2 | !/.gitattributes 3 | !/.projen/tasks.json 4 | !/.projen/deps.json 5 | !/.projen/files.json 6 | !/package.json 7 | !/LICENSE 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | lib-cov 20 | coverage 21 | *.lcov 22 | .nyc_output 23 | build/Release 24 | node_modules/ 25 | jspm_packages/ 26 | *.tsbuildinfo 27 | .eslintcache 28 | *.tgz 29 | .yarn-integrity 30 | .cache 31 | .idea/ 32 | .vscode/ 33 | .venv/ 34 | *.DS_Store 35 | deployment/open-source/ 36 | deployment/global-s3-assets 37 | deployment/regional-s3-assets 38 | __pycache__/ 39 | build/ 40 | internal/scripts/redpencil 41 | .temp_redpencil 42 | bom.json 43 | internal/scripts/cfn-guard 44 | deployment/test-reports 45 | deployment/coverage-reports 46 | !/jest.config.json 47 | !/.github/pull_request_template.md 48 | !/source/instance-scheduler/tests/ 49 | !/tsconfig.json 50 | !/source/ 51 | /lib 52 | /dist/ 53 | !/.projen/jest-snapshot-resolver.js 54 | /assets/ 55 | !/cdk.json 56 | /build/cdk.out/ 57 | .cdk.staging/ 58 | .parcel-cache/ 59 | !/source/pipeline/jest.config.json 60 | /coverage/ 61 | !/solution-manifest.yaml 62 | !/.projenrc.ts 63 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /.projen/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | ".gitattributes", 4 | ".github/pull_request_template.md", 5 | ".gitignore", 6 | ".projen/deps.json", 7 | ".projen/files.json", 8 | ".projen/jest-snapshot-resolver.js", 9 | ".projen/tasks.json", 10 | "cdk.json", 11 | "jest.config.json", 12 | "LICENSE", 13 | "solution-manifest.yaml", 14 | "source/pipeline/jest.config.json", 15 | "tsconfig.json" 16 | ], 17 | "//": "~~ Generated by projen. To modify, edit .projenrc.ts and run \"npx projen\"." 18 | } 19 | -------------------------------------------------------------------------------- /.projen/jest-snapshot-resolver.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const libtest = "lib/instance-scheduler/tests"; 3 | const srctest= "source/instance-scheduler/tests"; 4 | module.exports = { 5 | resolveSnapshotPath: (test, ext) => { 6 | const fullpath = test.replace(libtest, srctest); 7 | return path.join(path.dirname(fullpath), "__snapshots__", path.basename(fullpath, ".js") + ".ts" + ext); 8 | }, 9 | resolveTestPath: (snap, ext) => { 10 | const filename = path.basename(snap, ".ts" + ext) + ".js"; 11 | const dir = path.dirname(path.dirname(snap)).replace(srctest, libtest); 12 | return path.join(dir, filename); 13 | }, 14 | testPathForConsistencyCheck: path.join('some', '__tests__', 'example.test.js') 15 | }; -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Reporting Security Issues 2 | ---------------------------------------------------------------------------------------------------------- 3 | We take all security reports seriously. When we receive such reports, we will investigate and 4 | subsequently address any potential vulnerabilities as quickly as possible. If you discover a potential 5 | security issue in this project, please notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/) or 6 | directly via email to [AWS Security](mailto:aws-security@amazon.com). Please do not create a public GitHub issue in this project. -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node -P tsconfig.json --prefer-ts-exts source/instance-scheduler.ts", 3 | "context": { 4 | "solutionId": "SO0030", 5 | "solutionName": "instance-scheduler-on-aws", 6 | "appRegApplicationName": "AWS-Solutions", 7 | "appRegSolutionName": "instance-scheduler-on-aws", 8 | "instance-scheduler-on-aws-pipeline-source": "codecommit" 9 | }, 10 | "output": "build/cdk.out", 11 | "build": "npx projen bundle", 12 | "watch": { 13 | "include": [ 14 | "source/**/*.ts", 15 | "source/instance-scheduler/tests/**/*.ts" 16 | ], 17 | "exclude": [ 18 | "README.md", 19 | "cdk*.json", 20 | "**/*.d.ts", 21 | "**/*.js", 22 | "tsconfig.json", 23 | "package*.json", 24 | "yarn.lock", 25 | "node_modules" 26 | ] 27 | }, 28 | "//": "~~ Generated by projen. To modify, edit .projenrc.ts and run \"npx projen\"." 29 | } 30 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/README.md: -------------------------------------------------------------------------------- 1 | ## CDK Solution Helper 2 | 3 | **For aws-solutions internal purposes only** 4 | 5 | `cdk-solution-helper` runs on solution internal pipeline and makes needed artifact modifications to support 1-click deployment using solution CloudFormation template. 6 | Additionally, it packages templates and lambda binaries and prepares them to be staged on the solution buckets. -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/asset-packager/__tests__/handler.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import path from "path"; 5 | import { existsSync } from "fs"; 6 | import { mkdir, writeFile, rm } from "node:fs/promises"; 7 | import { handler } from "../index"; 8 | 9 | const __assetDirectoryPath = path.join(__dirname, "mock-dir"); 10 | const __outputPath = path.join(__dirname, "mock-dir-output"); 11 | describe("Handler", () => { 12 | beforeAll(async function Arrange() { 13 | await rm(__assetDirectoryPath, { recursive: true, force: true }); 14 | await rm(__outputPath, { recursive: true, force: true }); 15 | await mkdir(__assetDirectoryPath); 16 | await mkdir(__outputPath); 17 | }); 18 | 19 | it("should fail in absence of path inputs ", async function () { 20 | expect.assertions(2); 21 | await expect(handler("", "")).rejects.toThrowError("undefined input path"); 22 | await expect(handler(undefined, undefined)).rejects.toThrowError("undefined input path"); 23 | }); 24 | 25 | it("should fail for invalid cdk asset path", async function () { 26 | expect.assertions(1); 27 | await expect(handler("invalidPath", __outputPath)).rejects.toThrowError(/(ENOENT).+(invalidPath)/g); 28 | }); 29 | 30 | it("should succeed if cdk assets not found", async function () { 31 | await expect(handler(__assetDirectoryPath, "invalidPath")).resolves.toBeUndefined(); 32 | }); 33 | 34 | it("should fail for invalid output path", async function () { 35 | // Arrange 36 | expect.assertions(1); 37 | const mockAssetPath = path.join(__assetDirectoryPath, "./asset.cdkAsset.zip"); 38 | await writeFile(mockAssetPath, "NoOp"); 39 | // Act, Assert 40 | await expect(handler(__assetDirectoryPath, "invalidPath")).rejects.toThrowError(/(ENOENT).+(invalidPath)/g); 41 | // Cleanup 42 | await rm(mockAssetPath); 43 | }); 44 | 45 | it("should successfully stage zip for valid paths", async function () { 46 | const zipName = "asset.cdkAsset.zip"; 47 | const mockAssetPath = path.join(__assetDirectoryPath, zipName); 48 | await writeFile(mockAssetPath, "NoOp"); 49 | await expect(handler(__assetDirectoryPath, __outputPath)).resolves.toBeUndefined(); 50 | expect(existsSync(path.join(__outputPath, zipName.split("asset.").pop() ?? "unexpected"))).toBe(true); 51 | }); 52 | 53 | afterAll(async function Cleanup() { 54 | await rm(__assetDirectoryPath, { recursive: true, force: true }); 55 | await rm(__outputPath, { recursive: true, force: true }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/asset-packager/asset-packager.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { readdir, lstat, rename } from "node:fs/promises"; 5 | import path from "path"; 6 | import AdmZip from "adm-zip"; 7 | 8 | /** 9 | * @description Class to help with packaging and staging cdk assets 10 | * on solution internal pipelines 11 | */ 12 | export class CDKAssetPackager { 13 | constructor(private readonly assetFolderPath: string) {} 14 | 15 | /** 16 | * @description get cdk asset paths 17 | * All cdk generated assets are prepended with "asset" 18 | */ 19 | async getAssetPaths() { 20 | try { 21 | const allFiles = await readdir(this.assetFolderPath); 22 | const assetFilePaths = allFiles 23 | .filter((file) => file.includes("asset")) 24 | .map((file) => path.join(this.assetFolderPath, file)); 25 | return assetFilePaths; 26 | } catch (err) { 27 | console.error(err); 28 | return []; 29 | } 30 | } 31 | 32 | /** 33 | * @description creates zip from folder 34 | * @param folderPath 35 | */ 36 | async createAssetZip(folderPath: string) { 37 | const isDir = (await lstat(folderPath)).isDirectory(); 38 | if (isDir) { 39 | const zip = new AdmZip(); 40 | zip.addLocalFolder(path.join(folderPath, "./")); 41 | const zipName = `${folderPath.split("/").pop()}.zip`; 42 | zip.writeZip(path.join(this.assetFolderPath, zipName)); 43 | } 44 | } 45 | 46 | /** 47 | * @description moves zips to staging output directory in internal pipelines 48 | * @param outputPath 49 | */ 50 | async moveZips(outputPath: string) { 51 | const allFiles = await readdir(this.assetFolderPath); 52 | const allZipPaths = allFiles.filter((file) => path.extname(file) === ".zip"); 53 | for (const zipPath of allZipPaths) { 54 | const hashPart = zipPath.split("asset.").pop(); 55 | if (!hashPart) { 56 | throw new Error(`Unexpected path: {${zipPath}}`); 57 | } 58 | await rename(path.join(this.assetFolderPath, zipPath), path.join(outputPath, hashPart)); 59 | // remove cdk prepended string "asset.*" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/asset-packager/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { CDKAssetPackager } from "./asset-packager"; 4 | 5 | export async function handler(cdkAssetFolderPath: string | undefined, outputPath: string | undefined) { 6 | if (!cdkAssetFolderPath || !outputPath) throw new Error("undefined input path"); 7 | const assetPackager = new CDKAssetPackager(cdkAssetFolderPath); 8 | const assetPaths = await assetPackager.getAssetPaths(); 9 | for (const path of assetPaths) { 10 | await assetPackager.createAssetZip(path); 11 | } 12 | await assetPackager.moveZips(outputPath); 13 | } 14 | 15 | if (require.main === module) { 16 | // this module was run directly from the command line, getting command line arguments 17 | // e.g. npx ts-node index.ts cdkAssetPath outputPath 18 | const cdkAssetPath = process.argv[2]; 19 | const outputPath = process.argv[3]; 20 | handler(cdkAssetPath, outputPath) 21 | .then(() => console.log("all assets packaged")) 22 | .catch((err) => { 23 | console.error(err); 24 | throw err; 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/jest.config.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import type { Config } from "jest"; 4 | 5 | const config: Config = { 6 | collectCoverage: true, 7 | coverageDirectory: "coverage", 8 | transform: { 9 | "^.+\\.(t)sx?$": "ts-jest", 10 | }, 11 | collectCoverageFrom: ["**/*.ts", "!**/*.test.ts", "!./jest.config.ts", "!./jest.setup.ts"], 12 | coverageReporters: [["lcov", { projectRoot: "../" }], "text"], 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /deployment/cdk-solution-helper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk-solution-helper", 3 | "version": "1.0.0", 4 | "description": "helper to update references in cdk generated cfn template and package lambda assets", 5 | "main": "index.js", 6 | "author": { 7 | "name": "Amazon Web Services", 8 | "url": "https://aws.amazon.com/solutions", 9 | "organization": true 10 | }, 11 | "scripts": { 12 | "test": "jest --silent" 13 | }, 14 | "license": "Apache-2.0", 15 | "devDependencies": { 16 | "@types/adm-zip": "^0.5.5", 17 | "@types/jest": "^29.5.11", 18 | "@types/node": "^18.19.3", 19 | "jest": "^29.7.0", 20 | "ts-jest": "^29.1.1", 21 | "ts-node": "^10.9.1", 22 | "typescript": "~5.3.3" 23 | }, 24 | "dependencies": { 25 | "adm-zip": "^0.5.10", 26 | "aws-cdk-lib": "^2.114.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /deployment/run-unit-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: Apache-2.0 4 | [[ $DEBUG ]] && set -x 5 | set -eou pipefail 6 | 7 | main() { 8 | echo "Review README.md for instructions to run tests locally" 9 | exit 1 10 | } 11 | 12 | main "$@" 13 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import "jest-extended"; 5 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverageProvider": "v8", 3 | "reporters": [ 4 | "default", 5 | [ 6 | "jest-junit", 7 | { 8 | "outputDirectory": "deployment/test-reports", 9 | "outputName": "cdk-test-report.xml" 10 | } 11 | ] 12 | ], 13 | "roots": [ 14 | "/source/instance-scheduler/tests" 15 | ], 16 | "transform": { 17 | "^.+\\.tsx?$": "ts-jest" 18 | }, 19 | "setupFilesAfterEnv": [ 20 | "jest-extended/all" 21 | ], 22 | "testMatch": [ 23 | "/@(lib/instance-scheduler/tests)/**/*(*.)@(spec|test).js?(x)", 24 | "/@(lib/instance-scheduler/tests)/**/__tests__/**/*.js?(x)", 25 | "**/*.test.ts", 26 | "/@(projenrc)/**/*(*.)@(spec|test).ts?(x)", 27 | "/@(projenrc)/**/__tests__/**/*.ts?(x)" 28 | ], 29 | "clearMocks": true, 30 | "collectCoverage": true, 31 | "coverageReporters": [ 32 | "json", 33 | "lcov", 34 | "clover", 35 | "cobertura", 36 | "text" 37 | ], 38 | "coverageDirectory": "coverage", 39 | "coveragePathIgnorePatterns": [ 40 | "/node_modules/" 41 | ], 42 | "testPathIgnorePatterns": [ 43 | "/node_modules/" 44 | ], 45 | "watchPathIgnorePatterns": [ 46 | "/node_modules/", 47 | "/source/" 48 | ], 49 | "//": "~~ Generated by projen. To modify, edit .projenrc.ts and run \"npx projen\"." 50 | } 51 | -------------------------------------------------------------------------------- /projenrc/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | _Issue #, if available:_ 2 | 3 | _Description of changes:_ 4 | 5 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the 6 | terms of your choice. 7 | -------------------------------------------------------------------------------- /source/app/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = instance_scheduler 4 | 5 | [report] 6 | exclude_lines = 7 | if TYPE_CHECKING: 8 | -------------------------------------------------------------------------------- /source/app/.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | * text=auto eol=lf 4 | /.gitattributes linguist-generated 5 | /.gitignore linguist-generated 6 | /.projen/** linguist-generated 7 | /.projen/deps.json linguist-generated 8 | /.projen/files.json linguist-generated 9 | /.projen/tasks.json linguist-generated 10 | /pyproject.toml linguist-generated -------------------------------------------------------------------------------- /source/app/.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | node_modules/ 3 | !/.gitattributes 4 | !/.projen/tasks.json 5 | !/.projen/deps.json 6 | !/.projen/files.json 7 | !/pyproject.toml 8 | /poetry.toml 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | *.so 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | *.manifest 32 | *.spec 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | htmlcov/ 36 | .tox/ 37 | .nox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | *.py,cover 45 | .hypothesis/ 46 | .pytest_cache/ 47 | cover/ 48 | *.mo 49 | *.pot 50 | *.log 51 | local_settings.py 52 | db.sqlite3 53 | db.sqlite3-journal 54 | instance/ 55 | .webassets-cache 56 | .scrapy 57 | docs/_build/ 58 | .pybuilder/ 59 | target/ 60 | .ipynb_checkpoints 61 | profile_default/ 62 | ipython_config.py 63 | __pypackages__/ 64 | celerybeat-schedule 65 | celerybeat.pid 66 | *.sage.py 67 | .env 68 | .venv 69 | env/ 70 | venv/ 71 | ENV/ 72 | env.bak/ 73 | venv.bak/ 74 | .spyderproject 75 | .spyproject 76 | .ropeproject 77 | /site 78 | .mypy_cache/ 79 | .dmypy.json 80 | dmypy.json 81 | .pyre/ 82 | .pytype/ 83 | cython_debug/ 84 | -------------------------------------------------------------------------------- /source/app/.projen/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | ".gitattributes", 4 | ".gitignore", 5 | ".projen/deps.json", 6 | ".projen/files.json", 7 | ".projen/tasks.json", 8 | "poetry.toml", 9 | "pyproject.toml" 10 | ], 11 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 12 | } 13 | -------------------------------------------------------------------------------- /source/app/.projen/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "build": { 4 | "name": "build", 5 | "description": "Full release build", 6 | "steps": [ 7 | { 8 | "spawn": "pre-compile" 9 | }, 10 | { 11 | "spawn": "compile" 12 | }, 13 | { 14 | "spawn": "post-compile" 15 | }, 16 | { 17 | "spawn": "test" 18 | }, 19 | { 20 | "spawn": "package" 21 | } 22 | ] 23 | }, 24 | "compile": { 25 | "name": "compile", 26 | "description": "Only compile" 27 | }, 28 | "default": { 29 | "name": "default", 30 | "description": "Synthesize project files", 31 | "steps": [ 32 | { 33 | "exec": "npx projen default", 34 | "cwd": "../.." 35 | } 36 | ] 37 | }, 38 | "install": { 39 | "name": "install", 40 | "description": "Install dependencies and update lockfile", 41 | "steps": [ 42 | { 43 | "exec": "poetry lock --no-update && poetry install" 44 | } 45 | ] 46 | }, 47 | "install:ci": { 48 | "name": "install:ci", 49 | "description": "Install dependencies with frozen lockfile", 50 | "steps": [ 51 | { 52 | "exec": "poetry check --lock && poetry install" 53 | } 54 | ] 55 | }, 56 | "package": { 57 | "name": "package", 58 | "description": "Creates the distribution package", 59 | "steps": [ 60 | { 61 | "exec": "poetry build" 62 | } 63 | ] 64 | }, 65 | "post-compile": { 66 | "name": "post-compile", 67 | "description": "Runs after successful compilation" 68 | }, 69 | "pre-compile": { 70 | "name": "pre-compile", 71 | "description": "Prepare the project for compilation" 72 | }, 73 | "publish": { 74 | "name": "publish", 75 | "description": "Uploads the package to PyPI.", 76 | "steps": [ 77 | { 78 | "exec": "poetry publish" 79 | } 80 | ] 81 | }, 82 | "publish:test": { 83 | "name": "publish:test", 84 | "description": "Uploads the package against a test PyPI endpoint.", 85 | "steps": [ 86 | { 87 | "exec": "poetry publish -r testpypi" 88 | } 89 | ] 90 | }, 91 | "test": { 92 | "name": "test", 93 | "description": "Run tests" 94 | } 95 | }, 96 | "env": { 97 | "VIRTUAL_ENV": "$(poetry env info -p || poetry run poetry env info -p)", 98 | "PATH": "$(echo $(poetry env info -p)/bin:$PATH)" 99 | }, 100 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 101 | } 102 | -------------------------------------------------------------------------------- /source/app/README.md: -------------------------------------------------------------------------------- 1 | # Instance Scheduler on AWS 2 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import tomllib 4 | from pathlib import Path 5 | 6 | __version__ = "unknown" 7 | 8 | pyproject_toml_file_path = Path(__file__, "../../pyproject.toml").resolve() 9 | if pyproject_toml_file_path.exists() and pyproject_toml_file_path.is_file(): 10 | with open(pyproject_toml_file_path, "rb") as file: 11 | __version__ = tomllib.load(file)["tool"]["poetry"]["version"] 12 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/boto_retry/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Any, Optional 4 | 5 | from boto3 import Session 6 | 7 | from instance_scheduler.util import get_boto_config 8 | 9 | 10 | def get_client_with_standard_retry( 11 | service_name: str, region: Optional[str] = None, session: Optional[Session] = None 12 | ) -> Any: 13 | aws_session = session if session is not None else Session() 14 | 15 | result = aws_session.client( 16 | service_name=service_name, region_name=region, config=get_boto_config() 17 | ) 18 | 19 | return result 20 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/configuration/global_config_builder.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/instance-scheduler-on-aws/9451a826c03c64b09280b9172f9911ec1f31a4cb/source/app/instance_scheduler/configuration/global_config_builder.py -------------------------------------------------------------------------------- /source/app/instance_scheduler/configuration/running_period_dict_element.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import NotRequired, Optional, TypedDict 4 | 5 | from instance_scheduler.configuration.running_period import RunningPeriod 6 | 7 | 8 | class RunningPeriodDictElement(TypedDict): 9 | period: RunningPeriod 10 | instancetype: NotRequired[Optional[str]] 11 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/configuration/scheduling_context.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import datetime 4 | import time 5 | from collections.abc import Mapping 6 | from dataclasses import dataclass, field 7 | from typing import Optional, TypedDict 8 | from zoneinfo import ZoneInfo 9 | 10 | from instance_scheduler import configuration 11 | from instance_scheduler.configuration.instance_schedule import InstanceSchedule 12 | from instance_scheduler.util.time import is_aware 13 | 14 | 15 | class TagTemplate(TypedDict): 16 | Key: str 17 | Value: str 18 | 19 | 20 | @dataclass(frozen=True) 21 | class SchedulingContext: 22 | account_id: str 23 | service: str 24 | region: str 25 | current_dt: datetime.datetime 26 | default_timezone: ZoneInfo 27 | schedules: Mapping[str, InstanceSchedule] 28 | scheduling_interval_minutes: int 29 | started_tags: list[TagTemplate] = field(default_factory=list) 30 | stopped_tags: list[TagTemplate] = field(default_factory=list) 31 | 32 | def __post_init__(self) -> None: 33 | if not is_aware(self.current_dt): 34 | raise ValueError( 35 | f"SchedulingContext datetime must be timezone-Aware. Received: {self.current_dt}" 36 | ) 37 | 38 | def get_schedule(self, name: Optional[str]) -> Optional[InstanceSchedule]: 39 | """ 40 | Get a schedule by its name 41 | :param name: name of the schedule 42 | :return: Schedule, None f it does not exist 43 | """ 44 | if not name: 45 | return None 46 | return self.schedules[name] if name in self.schedules else None 47 | 48 | 49 | def get_time_from_string(timestr: Optional[str]) -> Optional[datetime.time]: 50 | """ 51 | Standardised method to build time object instance from time string 52 | :param timestr: string in format as defined in configuration.TIME_FORMAT_STRING 53 | :return: time object from time string, None if the time is invalid 54 | """ 55 | if not timestr: 56 | return None 57 | try: 58 | tm = time.strptime(timestr, configuration.TIME_FORMAT_STRING) 59 | except ValueError: 60 | return None 61 | return datetime.time(tm.tm_hour, tm.tm_min, 0) 62 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/configuration/ssm.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import re 4 | from typing import TYPE_CHECKING, Final, Iterator, Sequence 5 | 6 | from instance_scheduler.boto_retry import get_client_with_standard_retry 7 | 8 | if TYPE_CHECKING: 9 | from mypy_boto3_ssm import SSMClient 10 | else: 11 | SSMClient = object 12 | 13 | 14 | def replace_ssm_references_with_account_ids( 15 | raw_account_ids_list: Sequence[str], 16 | ) -> Iterator[str]: 17 | """ 18 | for any account ids provided in the format {param:[param-name]}, fetch the corresponding 19 | SSM parameter list and append it to the list of account_ids 20 | 21 | :param raw_account_ids_list: a raw list of account_ids that may or may not contain ssm_param references 22 | :return: a new list of account_ids after ssm_param references have been fetched 23 | """ 24 | REGEX_SSM_PARAM: Final = "{param:(.+?)}" 25 | 26 | for account_id in raw_account_ids_list: 27 | if re.match(REGEX_SSM_PARAM, account_id): 28 | param_names = re.findall(REGEX_SSM_PARAM, account_id) 29 | for ssm_account_id in fetch_account_ids_from_ssm_params(param_names): 30 | yield ssm_account_id 31 | else: 32 | yield account_id 33 | 34 | 35 | def fetch_account_ids_from_ssm_params(param_names: list[str]) -> list[str]: 36 | if len(param_names) == 0: 37 | return [] 38 | 39 | ssm_client: SSMClient = get_client_with_standard_retry("ssm") 40 | resp = ssm_client.get_parameters(Names=list(set(param_names))) # remove duplicates 41 | 42 | account_ids = [] 43 | for p in resp.get("Parameters", []): 44 | if p["Type"] == "StringList": 45 | account_ids += p["Value"].split(",") 46 | else: 47 | account_ids.append(p["Value"]) 48 | return account_ids 49 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/configuration/time_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import datetime 4 | import re 5 | import time 6 | 7 | TIME_FORMAT = "HH:MM" 8 | """human-readable time format that can be displayed to users if an input fails is_valid_time_str""" 9 | 10 | 11 | def is_valid_time_str(timestr: str) -> bool: 12 | """ 13 | verify that a string matches the time format expected by parse_time_str 14 | 15 | a human-readable representation of a valid time_format can be accessed as TIME_FORMAT 16 | """ 17 | return re.match(r"^([0|1]?\d|2[0-3]):[0-5]\d$", timestr) is not None 18 | 19 | 20 | def parse_time_str(timestr: str) -> datetime.time: 21 | """ 22 | Standardised method to build time object instance from time string 23 | :param timestr: string in format as defined in configuration.TIME_FORMAT_STRING 24 | :return: time object from time string, None if the time is invalid 25 | """ 26 | try: 27 | tm = time.strptime(timestr, "%H:%M") 28 | except ValueError: 29 | raise ValueError(f"Invalid time string {timestr}, must match {TIME_FORMAT}") 30 | return datetime.time(tm.tm_hour, tm.tm_min, 0) 31 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/cron/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/cron/cron_recurrence_expression.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | from instance_scheduler.cron.cron_to_running_period import ( 8 | monthday_cron_expr_contains, 9 | months_cron_expr_contains, 10 | weekday_cron_expr_contains, 11 | ) 12 | from instance_scheduler.cron.expression import CronAll, CronExpression 13 | from instance_scheduler.cron.parser import ( 14 | parse_monthdays_expr, 15 | parse_months_expr, 16 | parse_weekdays_expr, 17 | ) 18 | 19 | 20 | @dataclass(frozen=True) 21 | class CronRecurrenceExpression: 22 | """A cron recurrence expression for days and months, but not time of day""" 23 | 24 | monthdays: CronExpression = CronAll() 25 | months: CronExpression = CronAll() 26 | weekdays: CronExpression = CronAll() 27 | 28 | def to_asg_scheduled_action(self) -> Any: 29 | raise NotImplementedError 30 | 31 | @classmethod 32 | def parse( 33 | cls, 34 | *, 35 | monthdays: set[str] = {"*"}, 36 | months: set[str] = {"*"}, 37 | weekdays: set[str] = {"*"}, 38 | ) -> "CronRecurrenceExpression": 39 | return CronRecurrenceExpression( 40 | monthdays=parse_monthdays_expr(monthdays), 41 | months=parse_months_expr(months), 42 | weekdays=parse_weekdays_expr(weekdays), 43 | ) 44 | 45 | def contains(self, dt: datetime) -> bool: 46 | """Does `dt` satisfy the recurrence defined in `expr`""" 47 | # When both days-of-month and days-of-week are specified, the normal behavior for 48 | # cron is to trigger on a day that satisfies either expression. However, Instance 49 | # Scheduler has historically checked that a date satisfies all fields. If a field is 50 | # missing from the period definition, it is considered satisfied. This means that if 51 | # a running period is not missing (`None`) and not a wildcard for all values, only 52 | # days that satisfy the intersection of days-of-month and days-of-week satisfy the 53 | # expression. This is a departure from standard cron behavior that may surprise 54 | # customers. 55 | return all( 56 | ( 57 | monthday_cron_expr_contains(self.monthdays, dt), 58 | months_cron_expr_contains(self.months, dt), 59 | weekday_cron_expr_contains(self.weekdays, dt), 60 | ) 61 | ) 62 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/base.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Mapping 5 | from typing import TYPE_CHECKING, Any, Generic, TypeGuard, TypeVar 6 | 7 | from instance_scheduler.handler.environments.main_lambda_environment import ( 8 | MainLambdaEnv, 9 | ) 10 | 11 | if TYPE_CHECKING: 12 | from aws_lambda_powertools.utilities.typing import LambdaContext 13 | else: 14 | LambdaContext = object 15 | 16 | T = TypeVar("T") 17 | 18 | 19 | class MainHandler(ABC, Generic[T]): 20 | @classmethod 21 | @abstractmethod 22 | def is_handling_request(cls, event: Mapping[str, Any]) -> TypeGuard[T]: 23 | pass 24 | 25 | @abstractmethod 26 | def __init__(self, event: T, context: LambdaContext, env: MainLambdaEnv) -> None: 27 | pass 28 | 29 | @abstractmethod 30 | def handle_request(self) -> Any: 31 | pass 32 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/environments/asg_env.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from os import environ 5 | from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 6 | 7 | from instance_scheduler.util.app_env_utils import AppEnvError, env_to_bool 8 | 9 | 10 | @dataclass(frozen=True) 11 | class AsgEnv: 12 | user_agent_extra: str 13 | 14 | issues_topic_arn: str 15 | logger_raise_exceptions: bool 16 | 17 | config_table_name: str 18 | asg_scheduling_role_name: str 19 | default_timezone: ZoneInfo 20 | schedule_tag_key: str 21 | scheduled_tag_key: str 22 | rule_prefix: str 23 | 24 | @staticmethod 25 | def from_env() -> "AsgEnv": 26 | try: 27 | return AsgEnv( 28 | user_agent_extra=environ["USER_AGENT_EXTRA"], 29 | issues_topic_arn=environ["ISSUES_TOPIC_ARN"], 30 | logger_raise_exceptions=env_to_bool( 31 | environ.get("LOGGER_RAISE_EXCEPTIONS", "False") 32 | ), 33 | config_table_name=environ["CONFIG_TABLE"], 34 | asg_scheduling_role_name=environ["ASG_SCHEDULING_ROLE_NAME"], 35 | default_timezone=ZoneInfo(environ["DEFAULT_TIMEZONE"]), 36 | schedule_tag_key=environ["SCHEDULE_TAG_KEY"], 37 | scheduled_tag_key=environ["SCHEDULED_TAG_KEY"], 38 | rule_prefix=environ["RULE_PREFIX"], 39 | ) 40 | except ZoneInfoNotFoundError as err: 41 | raise AppEnvError(f"Invalid timezone: {err.args[0]}") from err 42 | except KeyError as err: 43 | raise AppEnvError( 44 | f"Missing required application environment variable: {err.args[0]}" 45 | ) from err 46 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/environments/asg_orch_env.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from os import environ 5 | 6 | from instance_scheduler.util.app_env_utils import AppEnvError, env_to_bool, env_to_list 7 | 8 | 9 | @dataclass(frozen=True) 10 | class AsgOrchEnv: 11 | user_agent_extra: str 12 | 13 | issues_topic_arn: str 14 | logger_raise_exceptions: bool 15 | 16 | config_table_name: str 17 | enable_schedule_hub_account: bool 18 | schedule_regions: list[str] 19 | asg_scheduler_name: str 20 | 21 | @staticmethod 22 | def from_env() -> "AsgOrchEnv": 23 | try: 24 | return AsgOrchEnv( 25 | user_agent_extra=environ["USER_AGENT_EXTRA"], 26 | issues_topic_arn=environ["ISSUES_TOPIC_ARN"], 27 | logger_raise_exceptions=env_to_bool( 28 | environ.get("LOGGER_RAISE_EXCEPTIONS", "False") 29 | ), 30 | config_table_name=environ["CONFIG_TABLE"], 31 | enable_schedule_hub_account=env_to_bool( 32 | environ["ENABLE_SCHEDULE_HUB_ACCOUNT"] 33 | ), 34 | schedule_regions=env_to_list(environ["SCHEDULE_REGIONS"]), 35 | asg_scheduler_name=environ["ASG_SCHEDULER_NAME"], 36 | ) 37 | except KeyError as err: 38 | raise AppEnvError( 39 | f"Missing required application environment variable: {err.args[0]}" 40 | ) from err 41 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/environments/main_lambda_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from os import environ 5 | 6 | from instance_scheduler.util.app_env_utils import AppEnvError, env_to_bool 7 | 8 | 9 | @dataclass(frozen=True) 10 | class MainLambdaEnv: 11 | log_group: str 12 | topic_arn: str 13 | solution_version: str 14 | enable_debug_logging: bool 15 | user_agent_extra: str 16 | enable_aws_organizations: bool 17 | config_table_name: str 18 | 19 | @classmethod 20 | def from_env(cls) -> "MainLambdaEnv": 21 | try: 22 | return MainLambdaEnv( 23 | log_group=environ["LOG_GROUP"], 24 | topic_arn=environ["ISSUES_TOPIC_ARN"], 25 | solution_version=environ["SOLUTION_VERSION"], 26 | enable_debug_logging=env_to_bool(environ["TRACE"]), 27 | user_agent_extra=environ["USER_AGENT_EXTRA"], 28 | enable_aws_organizations=env_to_bool( 29 | environ["ENABLE_AWS_ORGANIZATIONS"] 30 | ), 31 | config_table_name=environ["CONFIG_TABLE"], 32 | ) 33 | except KeyError as err: 34 | raise AppEnvError( 35 | f"Missing required application environment variable: {err.args[0]}" 36 | ) from err 37 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/environments/metrics_uuid_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from os import environ 5 | 6 | from instance_scheduler.util.app_env_utils import AppEnvError 7 | 8 | 9 | @dataclass 10 | class MetricsUuidEnvironment: 11 | user_agent_extra: str 12 | stack_id: str 13 | uuid_key: str 14 | 15 | @staticmethod 16 | def from_env() -> "MetricsUuidEnvironment": 17 | try: 18 | return MetricsUuidEnvironment( 19 | user_agent_extra=environ["USER_AGENT_EXTRA"], 20 | stack_id=environ["STACK_ID"], 21 | uuid_key=environ["UUID_KEY"], 22 | ) 23 | except KeyError as err: 24 | raise AppEnvError( 25 | f"Missing required application environment variable: {err.args[0]}" 26 | ) from err 27 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/environments/remote_registration_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from os import environ 5 | 6 | from instance_scheduler.util.app_env_utils import AppEnvError 7 | 8 | 9 | @dataclass 10 | class RemoteRegistrationEnvironment: 11 | user_agent_extra: str 12 | hub_registration_lambda_arn: str 13 | 14 | @staticmethod 15 | def from_env() -> "RemoteRegistrationEnvironment": 16 | try: 17 | return RemoteRegistrationEnvironment( 18 | user_agent_extra=environ["USER_AGENT_EXTRA"], 19 | hub_registration_lambda_arn=environ["HUB_REGISTRATION_LAMBDA_ARN"], 20 | ) 21 | except KeyError as err: 22 | raise AppEnvError( 23 | f"Missing required application environment variable: {err.args[0]}" 24 | ) from err 25 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/handler/setup_demo_data.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from instance_scheduler.model.period_definition import PeriodDefinition 4 | from instance_scheduler.model.period_identifier import PeriodIdentifier 5 | from instance_scheduler.model.schedule_definition import ScheduleDefinition 6 | 7 | DEMO_PERIODS = [ 8 | PeriodDefinition( 9 | name="working-days", 10 | description="Working days", 11 | weekdays={"mon-fri"}, 12 | ), 13 | PeriodDefinition( 14 | name="weekends", 15 | description="Days in weekend", 16 | weekdays={"sat-sun"}, 17 | ), 18 | PeriodDefinition( 19 | name="office-hours", 20 | description="Office hours", 21 | weekdays={"mon-fri"}, 22 | begintime="09:00", 23 | endtime="17:00", 24 | ), 25 | PeriodDefinition( 26 | name="first-monday-in-quarter", 27 | description="Every first monday of each quarter", 28 | weekdays={"mon#1"}, 29 | months={"jan/3"}, 30 | ), 31 | ] 32 | 33 | DEMO_SCHEDULES = [ 34 | ScheduleDefinition( 35 | name="seattle-office-hours", 36 | description="Office hours in Seattle (Pacific)", 37 | periods=[PeriodIdentifier("office-hours")], 38 | timezone="US/Pacific", 39 | ), 40 | ScheduleDefinition( 41 | name="uk-office-hours", 42 | description="Office hours in UK", 43 | periods=[PeriodIdentifier("office-hours")], 44 | timezone="Europe/London", 45 | ), 46 | ScheduleDefinition( 47 | name="stopped", 48 | description="Instances stopped", 49 | override_status="stopped", 50 | ), 51 | ScheduleDefinition( 52 | name="running", 53 | description="Instances running", 54 | override_status="running", 55 | ), 56 | ScheduleDefinition( 57 | name="scale-up-down", 58 | description="Vertical scaling on weekdays, based on UTC time", 59 | periods=[ 60 | PeriodIdentifier.of("weekends", "t2.nano"), 61 | PeriodIdentifier.of("working-days", "t2.micro"), 62 | ], 63 | timezone="UTC", 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/main.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import json 4 | import traceback 5 | from collections.abc import Mapping 6 | from datetime import datetime, timezone 7 | from time import time 8 | from typing import TYPE_CHECKING, Any, Final, Sequence 9 | 10 | from instance_scheduler import util 11 | from instance_scheduler.handler.base import MainHandler 12 | from instance_scheduler.handler.cfn_schedule import CfnScheduleHandler 13 | from instance_scheduler.handler.cli.cli_request_handler import CliRequestHandler 14 | from instance_scheduler.handler.config_resource import SchedulerSetupHandler 15 | from instance_scheduler.handler.environments.main_lambda_environment import ( 16 | MainLambdaEnv, 17 | ) 18 | from instance_scheduler.util.logger import Logger 19 | 20 | if TYPE_CHECKING: 21 | from aws_lambda_powertools.utilities.typing import LambdaContext 22 | else: 23 | LambdaContext = object 24 | 25 | LOG_STREAM = "InstanceScheduler-{:0>4d}{:0>2d}{:0>2d}" 26 | 27 | handlers: Final[Sequence[type[MainHandler[Any]]]] = ( 28 | SchedulerSetupHandler, 29 | CfnScheduleHandler, 30 | CliRequestHandler, 31 | ) 32 | 33 | 34 | def lambda_handler(event: Mapping[str, Any], context: LambdaContext) -> Any: 35 | dt = datetime.now(timezone.utc) 36 | env = MainLambdaEnv.from_env() 37 | log_stream = LOG_STREAM.format(dt.year, dt.month, dt.day) 38 | result = {} 39 | with Logger( 40 | log_group=env.log_group, 41 | log_stream=log_stream, 42 | topic_arn=env.topic_arn, 43 | debug=env.enable_debug_logging, 44 | ) as logger: 45 | logger.info("InstanceScheduler, version {}".format(env.solution_version)) 46 | 47 | logger.debug("Event is {}", util.safe_json(event, indent=3)) 48 | 49 | for handler_type in handlers: 50 | if handler_type.is_handling_request(event): 51 | start = time() 52 | handler = handler_type(event, context, env) 53 | logger.info("Handler is {}".format(handler_type.__name__)) 54 | try: 55 | result = handler.handle_request() 56 | except Exception as e: 57 | logger.error( 58 | "Error handling request {} by handler {}: ({})\n{}", 59 | json.dumps(event), 60 | handler_type.__name__, 61 | e, 62 | traceback.format_exc(), 63 | ) 64 | execution_time = round(float((time() - start)), 3) 65 | logger.info("Handling took {} seconds", execution_time) 66 | return result 67 | logger.debug( 68 | "Request was not handled, no handler was able to handle this type of request {}", 69 | json.dumps(event), 70 | ) 71 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/maint_win/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/maint_win/ssm_mw_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import TYPE_CHECKING, Final, Iterator 4 | 5 | from instance_scheduler.model import EC2SSMMaintenanceWindow 6 | from instance_scheduler.util.session_manager import AssumedRole 7 | 8 | if TYPE_CHECKING: 9 | from mypy_boto3_ssm import SSMClient 10 | else: 11 | SSMClient = object 12 | 13 | 14 | class SSMMWClient: 15 | 16 | def __init__(self, spoke_session: AssumedRole): 17 | self._spoke_session = spoke_session 18 | 19 | def get_mws_from_ssm(self) -> Iterator[EC2SSMMaintenanceWindow]: 20 | """ 21 | This function gets all the ssm windows which are enabled from SSM service. 22 | 23 | Returns: 24 | list of ssm windows 25 | """ 26 | ssm: Final[SSMClient] = self._spoke_session.client("ssm") 27 | paginator: Final = ssm.get_paginator("describe_maintenance_windows") 28 | for page in paginator.paginate( 29 | Filters=[{"Key": "Enabled", "Values": ["true"]}] 30 | ): 31 | for identity in page["WindowIdentities"]: 32 | yield EC2SSMMaintenanceWindow.from_identity( 33 | identity=identity, 34 | account_id=self._spoke_session.account, 35 | region=self._spoke_session.region, 36 | ) 37 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | """ 4 | Persistence models and stores for data types used by Instance Scheduler. 5 | 6 | Models 7 | Model are implemented as dataclasses. They are well-typed representations of the 8 | data types stored by Instance scheduler. They are validated on creation and frozen. 9 | 10 | Models implement two static constructors as required: 1/ from the service response 11 | describing that data type, and 2/ from the DyanmoDB GetItem response. They also 12 | implement a transformation to a `dict[str, AttributeValueTypeDef]` suitable for 13 | calls to DynamoDB PutItem, and a transformation to a `dict[str, str]` suitable for 14 | calls to DynamoDB DeleteItem. 15 | 16 | Stores 17 | Stores implement an interface for list, get, put, and delete as needed for a model. 18 | 19 | Stores are backed by Amazon DynamoDB. 20 | 21 | Maintenance windows 22 | Classes for persistence of representations of EC2 maintenance windows as implemented 23 | by AWS Systems Manager. 24 | 25 | Model: `EC2SSMMaintenanceWindow` 26 | Raises `EC2SSMMaintenanceWindowValidationError` on validation error 27 | Store: `EC2SSMMaintenanceWindowStore` 28 | """ 29 | from instance_scheduler.model.store.maint_win_store import EC2SSMMaintenanceWindowStore 30 | 31 | from .maint_win import EC2SSMMaintenanceWindow, EC2SSMMaintenanceWindowValidationError 32 | from .store.mw_store import MWStore 33 | 34 | __all__ = [ 35 | "EC2SSMMaintenanceWindow", 36 | "EC2SSMMaintenanceWindowStore", 37 | "MWStore", 38 | "EC2SSMMaintenanceWindowValidationError", 39 | ] 40 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/ddb_config_item.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING 5 | 6 | from instance_scheduler.model.ddb_item_utils import skip_if_empty 7 | 8 | if TYPE_CHECKING: 9 | from mypy_boto3_dynamodb.type_defs import AttributeValueTypeDef 10 | else: 11 | AttributeValueTypeDef = object 12 | 13 | 14 | @dataclass 15 | class DdbConfigItem: 16 | """ 17 | object representation of the config item stored in the dynamodb config table 18 | 19 | There can only ever be 1 config item stored in dynamodb as this item represents the global configuration 20 | data of the solution that may be updated dynamically (is not stored in the lambda environment) 21 | """ 22 | 23 | organization_id: str = "" 24 | remote_account_ids: list[str] = field(default_factory=list) 25 | 26 | def to_item(self) -> dict[str, AttributeValueTypeDef]: 27 | """Return this object as a dict suitable for a call to DynamoDB `put_item`""" 28 | return { 29 | "type": {"S": "config"}, 30 | "name": {"S": "scheduler"}, 31 | "organization_id": {"S": self.organization_id}, 32 | **skip_if_empty("remote_account_ids", {"SS": self.remote_account_ids}), 33 | } 34 | 35 | @classmethod 36 | def from_item(cls, item: dict[str, AttributeValueTypeDef]) -> "DdbConfigItem": 37 | return DdbConfigItem( 38 | organization_id=item.get("organization_id", {}).get("S", ""), 39 | remote_account_ids=list(item.get("remote_account_ids", {}).get("SS", [])), 40 | ) 41 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/period_identifier.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Optional 4 | 5 | 6 | class PeriodIdentifier(str): 7 | @property 8 | def name(self) -> str: 9 | return self.split("@")[0] 10 | 11 | @property 12 | def desired_type(self) -> Optional[str]: 13 | tokens = self.split("@") 14 | if len(tokens) > 1: 15 | return tokens[1] 16 | else: 17 | return None 18 | 19 | @classmethod 20 | def of( 21 | cls, period_name: str, instance_type: Optional[str] = None 22 | ) -> "PeriodIdentifier": 23 | if instance_type: 24 | return PeriodIdentifier(f"{period_name}@{instance_type}") 25 | else: 26 | return PeriodIdentifier(period_name) 27 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/ddb_transact_write.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import uuid 4 | from types import TracebackType 5 | from typing import TYPE_CHECKING, Optional, Self, Sequence 6 | 7 | if TYPE_CHECKING: 8 | from mypy_boto3_dynamodb import DynamoDBClient 9 | from mypy_boto3_dynamodb.type_defs import TransactWriteItemTypeDef 10 | else: 11 | DynamoDBClient = object 12 | TransactWriteItemTypeDef = object 13 | 14 | 15 | class WriteTransaction: 16 | """ 17 | A context manager object for a DynamoDB transact_write_items call. 18 | 19 | This transaction is will be automatically committed when __exit__ is called and may raise 20 | an exception when doing so 21 | 22 | refer to https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/transact_write_items.html# 23 | for details 24 | """ 25 | 26 | def __init__(self, client: DynamoDBClient) -> None: 27 | self._client = client 28 | self.transaction_items: list[TransactWriteItemTypeDef] = [] 29 | self.request_token = str(uuid.uuid4()) 30 | 31 | def __enter__(self) -> Self: 32 | return self 33 | 34 | def __exit__( 35 | self, 36 | exc_type: Optional[type[BaseException]], 37 | exc_val: Optional[BaseException], 38 | exc_tb: Optional[TracebackType], 39 | ) -> None: 40 | if exc_type is None: 41 | self._commit() 42 | else: 43 | pass # exceptions allowed to bubble up to calling context 44 | 45 | def add(self, items: Sequence[TransactWriteItemTypeDef]) -> None: 46 | self.transaction_items.extend(items) 47 | 48 | def _commit(self) -> None: 49 | self._client.transact_write_items( 50 | TransactItems=self.transaction_items, 51 | ClientRequestToken=self.request_token, 52 | ) 53 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/dynamo_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | import boto3 6 | 7 | from instance_scheduler.util import get_boto_config 8 | 9 | if TYPE_CHECKING: 10 | from mypy_boto3_dynamodb import DynamoDBClient 11 | else: 12 | DynamoDBClient = object 13 | 14 | 15 | # shared dynamodb client to minimize the number of KMS api calls needed to access encrypted dynamodb tables 16 | # note: KMS caching with dynamo is done on a per-connection (client) level 17 | _hub_dynamo_client: Optional[DynamoDBClient] = None 18 | 19 | 20 | def hub_dynamo_client() -> DynamoDBClient: 21 | global _hub_dynamo_client 22 | if not _hub_dynamo_client: 23 | new_client: DynamoDBClient = boto3.client("dynamodb", config=get_boto_config()) 24 | _hub_dynamo_client = new_client 25 | return _hub_dynamo_client 26 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/dynamo_mw_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from collections.abc import Iterator 4 | from typing import Final 5 | 6 | from instance_scheduler.model import MWStore 7 | from instance_scheduler.model.maint_win import EC2SSMMaintenanceWindow 8 | from instance_scheduler.model.store.dynamo_client import hub_dynamo_client 9 | 10 | 11 | class DynamoMWStore(MWStore): 12 | def __init__(self, table_name: str) -> None: 13 | self._table_name: Final = table_name 14 | 15 | def put(self, window: EC2SSMMaintenanceWindow) -> None: 16 | hub_dynamo_client().put_item(TableName=self._table_name, Item=window.to_item()) 17 | 18 | def delete(self, window: EC2SSMMaintenanceWindow) -> None: 19 | hub_dynamo_client().delete_item(TableName=self._table_name, Key=window.to_key()) 20 | 21 | def find_by_account_region( 22 | self, account: str, region: str 23 | ) -> Iterator[EC2SSMMaintenanceWindow]: 24 | primary_key: Final = f"{account}:{region}" 25 | paginator: Final = hub_dynamo_client().get_paginator("query") 26 | for page in paginator.paginate( 27 | TableName=self._table_name, 28 | ExpressionAttributeNames={"#pk": "account-region"}, 29 | ExpressionAttributeValues={":val": {"S": primary_key}}, 30 | KeyConditionExpression="#pk = :val", 31 | ): 32 | for item in page["Items"]: 33 | yield EC2SSMMaintenanceWindow.from_item(item) 34 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/in_memory_mw_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Iterator, Mapping, Optional 4 | 5 | from instance_scheduler.model import EC2SSMMaintenanceWindow, MWStore 6 | 7 | AccountRegionPK = str 8 | NameIDSK = str 9 | 10 | 11 | class InMemoryMWStore(MWStore): 12 | 13 | _data: dict[AccountRegionPK, dict[NameIDSK, EC2SSMMaintenanceWindow]] 14 | 15 | def __init__( 16 | self, 17 | initial_data: Optional[ 18 | Mapping[AccountRegionPK, dict[NameIDSK, EC2SSMMaintenanceWindow]] 19 | ] = None, 20 | ): 21 | self._data = dict(initial_data) if initial_data else dict() 22 | 23 | def put(self, window: EC2SSMMaintenanceWindow) -> None: 24 | if window.account_region not in self._data: 25 | self._data[window.account_region] = dict() 26 | self._data[window.account_region][window.name_id] = window 27 | 28 | def delete(self, window: EC2SSMMaintenanceWindow) -> None: 29 | if window.account_region in self._data: 30 | self._data[window.account_region].pop(window.name_id) 31 | 32 | def find_by_account_region( 33 | self, account: str, region: str 34 | ) -> Iterator[EC2SSMMaintenanceWindow]: 35 | account_region = to_account_region_pk(account, region) 36 | if account_region in self._data: 37 | return iter(self._data[account_region].values()) 38 | else: 39 | return iter([]) 40 | 41 | 42 | def to_account_region_pk(account: str, region: str) -> AccountRegionPK: 43 | return f"{account}:{region}" 44 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/maint_win_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from collections.abc import Iterator 4 | from typing import Final 5 | 6 | from instance_scheduler.model.maint_win import EC2SSMMaintenanceWindow 7 | from instance_scheduler.model.store.dynamo_client import hub_dynamo_client 8 | 9 | 10 | class EC2SSMMaintenanceWindowStore: 11 | def __init__(self, table_name: str) -> None: 12 | self._client: Final = hub_dynamo_client() 13 | self._table_name: Final = table_name 14 | 15 | def get_ssm_windows_db( 16 | self, *, account: str, region: str 17 | ) -> Iterator[EC2SSMMaintenanceWindow]: 18 | primary_key: Final = f"{account}:{region}" 19 | paginator: Final = self._client.get_paginator("query") 20 | for page in paginator.paginate( 21 | TableName=self._table_name, 22 | ExpressionAttributeNames={"#pk": "account-region"}, 23 | ExpressionAttributeValues={":val": {"S": primary_key}}, 24 | KeyConditionExpression="#pk = :val", 25 | ): 26 | for item in page["Items"]: 27 | yield EC2SSMMaintenanceWindow.from_item(item) 28 | 29 | def put_window_dynamodb(self, window: EC2SSMMaintenanceWindow) -> None: 30 | self._client.put_item(TableName=self._table_name, Item=window.to_item()) 31 | 32 | def delete_window(self, window: EC2SSMMaintenanceWindow) -> None: 33 | self._client.delete_item(TableName=self._table_name, Key=window.to_key()) 34 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/mw_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Iterator 5 | 6 | from instance_scheduler.model.maint_win import EC2SSMMaintenanceWindow 7 | 8 | 9 | class MWStore(ABC): 10 | """ 11 | An abstract DAO layer between the rest of the app and the underlying persistence engine being used to 12 | store SSM Maintenance Windows 13 | """ 14 | 15 | @abstractmethod 16 | def put(self, window: EC2SSMMaintenanceWindow) -> None: 17 | raise NotImplementedError() 18 | 19 | @abstractmethod 20 | def delete(self, window: EC2SSMMaintenanceWindow) -> None: 21 | raise NotImplementedError() 22 | 23 | @abstractmethod 24 | def find_by_account_region( 25 | self, account: str, region: str 26 | ) -> Iterator[EC2SSMMaintenanceWindow]: 27 | raise NotImplementedError() 28 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/period_definition_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Mapping 5 | from typing import Optional 6 | 7 | from instance_scheduler.model.period_definition import PeriodDefinition 8 | 9 | 10 | class UnknownPeriodException(Exception): 11 | pass 12 | 13 | 14 | class PeriodAlreadyExistsException(Exception): 15 | pass 16 | 17 | 18 | class PeriodDefinitionStore(ABC): 19 | @abstractmethod 20 | def put(self, period: PeriodDefinition, overwrite: bool = False) -> None: 21 | raise NotImplementedError() 22 | 23 | @abstractmethod 24 | def delete(self, period_name: str, error_if_missing: bool = False) -> None: 25 | raise NotImplementedError() 26 | 27 | @abstractmethod 28 | def find_by_name(self, period_name: str) -> Optional[PeriodDefinition]: 29 | raise NotImplementedError() 30 | 31 | @abstractmethod 32 | def find_all(self) -> Mapping[str, PeriodDefinition]: 33 | raise NotImplementedError() 34 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/model/store/schedule_definition_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Mapping 5 | from typing import Optional 6 | 7 | from instance_scheduler.model.schedule_definition import ScheduleDefinition 8 | 9 | 10 | class UnknownScheduleException(Exception): 11 | pass 12 | 13 | 14 | class ScheduleAlreadyExistsException(Exception): 15 | pass 16 | 17 | 18 | class ScheduleDefinitionStore(ABC): 19 | @abstractmethod 20 | def put(self, schedule: ScheduleDefinition, overwrite: bool = False) -> None: 21 | raise NotImplementedError() 22 | 23 | @abstractmethod 24 | def delete(self, schedule_name: str, error_if_missing: bool = False) -> None: 25 | raise NotImplementedError() 26 | 27 | @abstractmethod 28 | def find_by_name(self, schedule_name: str) -> Optional[ScheduleDefinition]: 29 | raise NotImplementedError() 30 | 31 | @abstractmethod 32 | def find_by_period(self, period_name: str) -> Mapping[str, ScheduleDefinition]: 33 | raise NotImplementedError() 34 | 35 | @abstractmethod 36 | def find_all(self) -> Mapping[str, ScheduleDefinition]: 37 | raise NotImplementedError() 38 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from enum import Enum 4 | 5 | 6 | class GatheringFrequency(str, Enum): 7 | UNLIMITED = "UNLIMITED" 8 | DAILY = "DAILY" 9 | WEEKLY = "WEEKLY" 10 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/anonymous_metric_wrapper.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | 5 | from instance_scheduler.ops_metrics.metric_type.ops_metric import OpsMetric 6 | 7 | 8 | @dataclass(frozen=True) 9 | class AnonymousMetricWrapper: 10 | timestamp: str 11 | uuid: str 12 | solution: str 13 | version: str 14 | event_name: str 15 | context_version: int 16 | context: OpsMetric 17 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/metric_type/asg_count_metric.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from dataclasses import dataclass 5 | from typing import ClassVar 6 | 7 | from instance_scheduler.ops_metrics import GatheringFrequency 8 | from instance_scheduler.ops_metrics.metric_type.instance_count_metric import ( 9 | InstanceCountMetric, 10 | ) 11 | 12 | 13 | @dataclass(frozen=True) 14 | class AsgCountMetric(InstanceCountMetric): 15 | collection_frequency: ClassVar[GatheringFrequency] = GatheringFrequency.UNLIMITED 16 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/metric_type/cli_request_metric.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from typing import ClassVar 5 | 6 | from instance_scheduler.ops_metrics import GatheringFrequency 7 | from instance_scheduler.ops_metrics.metric_type.ops_metric import OpsMetric 8 | 9 | 10 | @dataclass(frozen=True) 11 | class CliRequestMetric(OpsMetric): 12 | command_used: str 13 | event_name: ClassVar[str] = "cli_request" 14 | collection_frequency: ClassVar[GatheringFrequency] = GatheringFrequency.UNLIMITED 15 | context_version: ClassVar[int] = 1 16 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/metric_type/deployment_description_metric.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from typing import ClassVar 5 | 6 | from instance_scheduler.ops_metrics import GatheringFrequency 7 | from instance_scheduler.ops_metrics.metric_type.ops_metric import OpsMetric 8 | 9 | 10 | @dataclass() 11 | class ScheduleFlagCounts: 12 | stop_new_instances: int = 0 13 | enforced: int = 0 14 | retain_running: int = 0 15 | hibernate: int = 0 16 | override: int = 0 17 | use_ssm_maintenance_window: int = 0 18 | non_default_timezone: int = 0 19 | 20 | 21 | @dataclass(frozen=True) 22 | class DeploymentDescriptionMetric(OpsMetric): 23 | services: list[str] 24 | regions: list[str] 25 | num_accounts: int 26 | num_schedules: int 27 | num_cfn_schedules: int 28 | num_one_sided_schedules: int 29 | approximate_lambda_payload_size_bytes: int 30 | schedule_flag_counts: ScheduleFlagCounts 31 | default_timezone: str 32 | create_rds_snapshots: bool 33 | schedule_interval_minutes: int 34 | memory_size_mb: int 35 | using_organizations: bool 36 | enable_ec2_ssm_maintenance_windows: bool 37 | ops_dashboard_enabled: bool 38 | num_started_tags: int 39 | num_stopped_tags: int 40 | event_name: ClassVar[str] = "deployment_description" 41 | collection_frequency: ClassVar[GatheringFrequency] = GatheringFrequency.DAILY 42 | context_version: ClassVar[int] = 2 43 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/metric_type/insights_metric.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from dataclasses import dataclass 5 | from datetime import datetime 6 | from typing import TYPE_CHECKING, ClassVar 7 | 8 | from instance_scheduler.ops_metrics import GatheringFrequency 9 | from instance_scheduler.ops_metrics.metric_type.ops_metric import OpsMetric 10 | from instance_scheduler.ops_monitoring.instance_counts import ServiceInstanceCounts 11 | 12 | if TYPE_CHECKING: 13 | from mypy_boto3_cloudwatch.literals import StandardUnitType 14 | from mypy_boto3_cloudwatch.type_defs import MetricDatumTypeDef 15 | else: 16 | MetricDatumTypeDef = object 17 | StandardUnitType = object 18 | 19 | 20 | @dataclass(frozen=True) 21 | class Dimension: 22 | name: str 23 | value: str 24 | 25 | 26 | @dataclass(frozen=True) 27 | class MetricDataItem: 28 | metric_name: str 29 | dimensions: list[Dimension] 30 | timestamp: datetime 31 | value: float 32 | unit: StandardUnitType 33 | 34 | def to_cloudwatch_data(self) -> MetricDatumTypeDef: 35 | return { 36 | "MetricName": self.metric_name, 37 | "Dimensions": [ 38 | {"Name": dimension.name, "Value": dimension.value} 39 | for dimension in self.dimensions 40 | ], 41 | "Timestamp": self.timestamp, 42 | "Value": self.value, 43 | "Unit": self.unit, 44 | } 45 | 46 | 47 | @dataclass(frozen=True) 48 | class InsightsMetric(OpsMetric): 49 | metric_data: list[MetricDataItem] 50 | event_name: ClassVar[str] = "insights_metric" 51 | collection_frequency: ClassVar[GatheringFrequency] = GatheringFrequency.UNLIMITED 52 | context_version: ClassVar[int] = 1 53 | 54 | @classmethod 55 | def from_service_counts( 56 | cls, service_counts: ServiceInstanceCounts, scheduling_interval_minutes: int 57 | ) -> "InsightsMetric": 58 | # imported here to avoid circular import 59 | from instance_scheduler.ops_monitoring.cw_ops_insights import ( 60 | CloudWatchOperationalInsights, 61 | ) 62 | 63 | return InsightsMetric( 64 | metric_data=CloudWatchOperationalInsights.build_per_instance_type_metrics( 65 | service_counts, scheduling_interval_minutes=scheduling_interval_minutes 66 | ), 67 | ) 68 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/metric_type/instance_count_metric.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from typing import ClassVar 5 | 6 | from instance_scheduler.ops_metrics import GatheringFrequency 7 | from instance_scheduler.ops_metrics.metric_type.ops_metric import OpsMetric 8 | 9 | 10 | @dataclass(frozen=True) 11 | class InstanceCountMetric(OpsMetric): 12 | service: str 13 | region: str 14 | num_instances: int 15 | num_schedules: int 16 | event_name: ClassVar[str] = "instance_count" 17 | collection_frequency: ClassVar[GatheringFrequency] = GatheringFrequency.DAILY 18 | context_version: ClassVar[int] = 1 19 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/metric_type/ops_metric.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from abc import ABC 4 | from dataclasses import dataclass 5 | from typing import ClassVar 6 | 7 | from instance_scheduler.ops_metrics import GatheringFrequency 8 | 9 | 10 | @dataclass(frozen=True) 11 | class OpsMetric(ABC): 12 | collection_frequency: ClassVar[GatheringFrequency] = GatheringFrequency.UNLIMITED 13 | event_name: ClassVar[str] 14 | context_version: ClassVar[int] 15 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_metrics/metric_type/scheduling_action_metric.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from typing import ClassVar 5 | 6 | from instance_scheduler.ops_metrics import GatheringFrequency 7 | from instance_scheduler.ops_metrics.metric_type.ops_metric import OpsMetric 8 | 9 | 10 | @dataclass(frozen=True) 11 | class ActionTaken: 12 | instances: int 13 | action: str # Literal["started", "stopped", "resized"] 14 | service: str # Literal["ec2", "rds"] 15 | instanceType: str 16 | 17 | 18 | @dataclass(frozen=True) 19 | class SchedulingActionMetric(OpsMetric): 20 | num_unique_schedules: ( 21 | int # num schedules configured in that region, not the number that took action 22 | ) 23 | num_instances_scanned: int 24 | duration_seconds: float 25 | actions: list[ActionTaken] 26 | event_name: ClassVar[str] = "scheduling_action" 27 | collection_frequency: ClassVar[GatheringFrequency] = GatheringFrequency.UNLIMITED 28 | context_version: ClassVar[int] = 1 29 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/ops_monitoring/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/instance-scheduler-on-aws/9451a826c03c64b09280b9172f9911ec1f31a4cb/source/app/instance_scheduler/ops_monitoring/__init__.py -------------------------------------------------------------------------------- /source/app/instance_scheduler/schedulers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/schedulers/scheduling_decision.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import dataclasses 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | from instance_scheduler.schedulers.states import InstanceState 8 | from instance_scheduler.service.abstract_instance import AbstractInstance 9 | 10 | 11 | class SchedulingAction(Enum): 12 | DO_NOTHING = None 13 | START = "start" 14 | STOP = "stop" 15 | 16 | 17 | @dataclasses.dataclass 18 | class SchedulingDecision: 19 | instance: AbstractInstance 20 | action: SchedulingAction 21 | new_state_table_state: Optional[InstanceState] 22 | reason: str 23 | desired_size: Optional[str] = None 24 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/schedulers/states.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from enum import Enum 4 | from typing import Optional, TypeGuard 5 | 6 | 7 | class ScheduleState(str, Enum): 8 | """possible desired states from a Schedule""" 9 | 10 | RUNNING = "running" 11 | ANY = "any" 12 | STOPPED = "stopped" 13 | 14 | 15 | class InstanceState(str, Enum): 16 | """additional states used for scheduling that can be saved to a specific instance""" 17 | 18 | RUNNING = "running" 19 | ANY = "any" 20 | STOPPED = "stopped" 21 | UNKNOWN = "unknown" 22 | STOPPED_FOR_RESIZE = "stopped_for_resize" 23 | RETAIN_RUNNING = "retain-running" 24 | START_FAILED = "start_failed" 25 | 26 | 27 | def is_valid_instance_state(value: Optional[str]) -> TypeGuard[InstanceState]: 28 | return any(value == state for state in InstanceState) 29 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/service/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from .base import Service 4 | from .ec2 import Ec2Service 5 | from .rds import RdsService 6 | 7 | __all__ = [ 8 | "Ec2Service", 9 | "Service", 10 | "RdsService", 11 | ] 12 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/service/abstract_instance.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass 5 | from typing import Sequence 6 | 7 | from instance_scheduler.configuration.instance_schedule import InstanceSchedule 8 | 9 | 10 | @dataclass(kw_only=True) 11 | class AbstractInstance(ABC): 12 | _id: str 13 | _name: str 14 | _schedule_name: str 15 | _current_state: str 16 | _instance_type: str 17 | _tags: dict[str, str] 18 | _maintenance_windows: Sequence[InstanceSchedule] 19 | 20 | # mutable leftovers from original resizing/hibernate design of EC2, should probably be refactored 21 | resized: bool = False 22 | should_hibernate: bool = False 23 | 24 | @property 25 | def id(self) -> str: 26 | return self._id 27 | 28 | @property 29 | def name(self) -> str: 30 | return self._name 31 | 32 | @property 33 | def schedule_name(self) -> str: 34 | return self._schedule_name 35 | 36 | @property 37 | def current_state(self) -> str: 38 | return self._current_state 39 | 40 | @property 41 | def tags(self) -> dict[str, str]: 42 | return self._tags 43 | 44 | @property 45 | def instance_type(self) -> str: 46 | return self._instance_type 47 | 48 | @property 49 | def maintenance_windows(self) -> Sequence[InstanceSchedule]: 50 | return self._maintenance_windows 51 | 52 | @property 53 | @abstractmethod 54 | def display_str(self) -> str: 55 | pass 56 | 57 | @property 58 | @abstractmethod 59 | def is_schedulable(self) -> bool: 60 | pass 61 | 62 | @property 63 | @abstractmethod 64 | def is_running(self) -> bool: 65 | pass 66 | 67 | @property 68 | @abstractmethod 69 | def is_stopped(self) -> bool: 70 | pass 71 | 72 | @property 73 | @abstractmethod 74 | def is_resizable(self) -> bool: 75 | pass 76 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/service/base.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Iterator 5 | from typing import Generic, TypeVar 6 | 7 | from instance_scheduler.schedulers.states import ScheduleState 8 | from instance_scheduler.service.abstract_instance import AbstractInstance 9 | 10 | T = TypeVar("T", bound=AbstractInstance) 11 | 12 | 13 | class Service(Generic[T], ABC): 14 | 15 | @property 16 | @abstractmethod 17 | def service_name(self) -> str: 18 | pass 19 | 20 | @abstractmethod 21 | def describe_tagged_instances(self) -> Iterator[T]: 22 | pass 23 | 24 | @abstractmethod 25 | def start_instances( 26 | self, instances_to_start: list[T] 27 | ) -> Iterator[tuple[T, Exception]]: 28 | """start a collection of instances 29 | 30 | :returns: a tuple stream of instances that fail to start: (instance, exception) 31 | """ 32 | pass 33 | 34 | @abstractmethod 35 | def stop_instances( 36 | self, instances_to_stop: list[T] 37 | ) -> Iterator[tuple[str, ScheduleState]]: 38 | pass 39 | 40 | @abstractmethod 41 | def resize_instance(self, instance: T, instance_type: str) -> None: 42 | pass 43 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/service/ec2_instance.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING 5 | 6 | from instance_scheduler.service.abstract_instance import AbstractInstance 7 | 8 | if TYPE_CHECKING: 9 | from mypy_boto3_ec2.literals import InstanceStateNameType, InstanceTypeType 10 | else: 11 | InstanceStateNameType = object 12 | InstanceTypeType = object 13 | 14 | 15 | @dataclass(kw_only=True) 16 | class EC2Instance(AbstractInstance): 17 | _current_state: InstanceStateNameType 18 | _instance_type: InstanceTypeType 19 | 20 | @property 21 | def display_str(self) -> str: 22 | s = f"EC2:{self.id}" 23 | if self.name: 24 | s += " ({})".format(self.name) 25 | return s 26 | 27 | @property 28 | def is_schedulable(self) -> bool: 29 | return self.current_state in ["running", "stopped"] 30 | 31 | @property 32 | def is_running(self) -> bool: 33 | return self.current_state == "running" 34 | 35 | @property 36 | def is_stopped(self) -> bool: 37 | return self.current_state == "stopped" 38 | 39 | @property 40 | def is_resizable(self) -> bool: 41 | return True 42 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/service/rds_instance.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | 5 | from instance_scheduler.service.abstract_instance import AbstractInstance 6 | 7 | 8 | @dataclass(kw_only=True) 9 | class RdsInstance(AbstractInstance): 10 | _is_cluster: bool 11 | _arn: str 12 | _engine_type: str 13 | 14 | @property 15 | def display_str(self) -> str: 16 | s = f"RDS:{self._engine_type}:{self.id}" 17 | if self.name: 18 | s += " ({})".format(self.name) 19 | return s 20 | 21 | @property 22 | def arn(self) -> str: 23 | return self._arn 24 | 25 | @property 26 | def is_cluster(self) -> bool: 27 | return self._is_cluster 28 | 29 | @property 30 | def is_schedulable(self) -> bool: 31 | return self._current_state in ["available", "stopped"] 32 | 33 | @property 34 | def is_running(self) -> bool: 35 | return self._current_state == "available" 36 | 37 | @property 38 | def is_stopped(self) -> bool: 39 | return self._current_state == "stopped" 40 | 41 | @property 42 | def is_resizable(self) -> bool: 43 | return False 44 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import json as _json 4 | from os import environ 5 | from typing import Any as _Any 6 | 7 | from botocore.config import Config as _Config 8 | 9 | from instance_scheduler.util.custom_encoder import CustomEncoder as _CustomEncoder 10 | 11 | 12 | def safe_json(d: _Any, indent: int = 0) -> str: 13 | """ 14 | Returns a json document, using a custom encoder that converts all data types not supported by json 15 | :param d: input dictionary 16 | :param indent: indent level for output document 17 | :return: json document for input dictionary 18 | """ 19 | return _json.dumps(d, cls=_CustomEncoder, indent=indent) 20 | 21 | 22 | def get_boto_config() -> _Config: 23 | """Returns a boto3 config with standard retries and `user_agent_extra`""" 24 | return _Config( 25 | retries={"max_attempts": 5, "mode": "standard"}, 26 | user_agent_extra=environ[ 27 | "USER_AGENT_EXTRA" 28 | ], # todo: don't access environ directly here 29 | ) 30 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/app_env_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | class AppEnvError(RuntimeError): 4 | pass 5 | 6 | 7 | def env_to_bool(value: str) -> bool: 8 | return value.strip().lower() in {"true", "yes"} 9 | 10 | 11 | def env_to_list(value: str) -> list[str]: 12 | items = [] 13 | for item in value.split(","): 14 | stripped = item.strip() 15 | if stripped: 16 | items.append(stripped) 17 | return items 18 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/custom_encoder.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import decimal 4 | import json 5 | from datetime import datetime 6 | from typing import Any 7 | 8 | 9 | class CustomEncoder(json.JSONEncoder): 10 | """ 11 | Internal class used for serialization of types not supported in json. 12 | """ 13 | 14 | def default(self, o: Any) -> Any: 15 | # sets become lists 16 | if isinstance(o, set): 17 | return list(o) 18 | # datetimes become strings 19 | if isinstance(o, datetime): 20 | return o.isoformat() 21 | if isinstance(o, decimal.Decimal): 22 | return float(o) 23 | if isinstance(o, type): 24 | return str(o) 25 | 26 | return json.JSONEncoder.default(self, o) 27 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/dynamodb_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import boto3 6 | 7 | from instance_scheduler.util import get_boto_config 8 | 9 | if TYPE_CHECKING: 10 | from mypy_boto3_dynamodb.service_resource import Table 11 | else: 12 | Table = object 13 | 14 | 15 | class DynamoDBUtils: 16 | @staticmethod 17 | def get_dynamodb_table_resource_ref( 18 | table_name: str, 19 | ) -> Any: # todo: switch typing to "Table" 20 | table: Table = boto3.resource("dynamodb", config=get_boto_config()).Table( 21 | table_name 22 | ) 23 | return table 24 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/pagination.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Iterator, TypeVar 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def paginate(array: list[T], page_size: int) -> Iterator[list[T]]: 9 | for i in range(0, len(array), page_size): 10 | yield array[i : i + page_size] 11 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/scheduling_target.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from collections.abc import Iterator 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Union 6 | 7 | import boto3 8 | from aws_lambda_powertools import Logger as PowerToolsLogger 9 | 10 | from instance_scheduler.configuration.ssm import replace_ssm_references_with_account_ids 11 | from instance_scheduler.handler.environments.asg_orch_env import AsgOrchEnv 12 | from instance_scheduler.handler.environments.orchestrator_environment import ( 13 | OrchestratorEnvironment, 14 | ) 15 | from instance_scheduler.model.ddb_config_item import DdbConfigItem 16 | from instance_scheduler.util.logger import Logger 17 | 18 | if TYPE_CHECKING: 19 | from aws_lambda_powertools.utilities.typing import LambdaContext 20 | else: 21 | LambdaContext = object 22 | 23 | 24 | @dataclass(frozen=True) 25 | class SchedulingTarget: 26 | account: str 27 | service: str 28 | region: str 29 | 30 | def __str__(self) -> str: 31 | return f"{self.account}-{self.region}-{self.service}" 32 | 33 | 34 | def get_account_ids( 35 | ddb_config_item: DdbConfigItem, 36 | env: Union[OrchestratorEnvironment, AsgOrchEnv], 37 | logger: Union[Logger, PowerToolsLogger], 38 | context: LambdaContext, 39 | ) -> Iterator[str]: 40 | """ 41 | Iterates account and cross-account-roles of the accounts to operate on 42 | :return: 43 | """ 44 | processed_accounts = [] 45 | hub_account_id = context.invoked_function_arn.split(":")[4] 46 | 47 | if env.enable_schedule_hub_account: 48 | processed_accounts.append(hub_account_id) 49 | yield hub_account_id 50 | 51 | for remote_account in replace_ssm_references_with_account_ids( 52 | ddb_config_item.remote_account_ids 53 | ): 54 | if not remote_account: 55 | continue 56 | 57 | if remote_account in processed_accounts: 58 | logger.warning("Remote account {} is already processed", remote_account) 59 | continue 60 | 61 | yield remote_account 62 | 63 | 64 | def list_all_targets( 65 | ddb_config_item: DdbConfigItem, 66 | env: OrchestratorEnvironment, 67 | logger: Union[Logger, PowerToolsLogger], 68 | context: LambdaContext, 69 | ) -> Iterator[SchedulingTarget]: 70 | """ 71 | Iterates account and cross-account-roles of the accounts to operate on 72 | :return: 73 | """ 74 | services = env.scheduled_services() 75 | regions = ( 76 | env.schedule_regions if env.schedule_regions else [boto3.Session().region_name] 77 | ) 78 | 79 | for service in services: 80 | for region in regions: 81 | for account in get_account_ids(ddb_config_item, env, logger, context): 82 | yield SchedulingTarget(account=account, service=service, region=region) 83 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/sns_handler.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from logging import WARNING, Handler, LogRecord 4 | from typing import TYPE_CHECKING, Final 5 | 6 | from instance_scheduler import boto_retry 7 | 8 | if TYPE_CHECKING: 9 | from mypy_boto3_sns.client import SNSClient 10 | else: 11 | SNSClient = object 12 | 13 | 14 | class SnsHandler(Handler): # NOSONAR 15 | def __init__( 16 | self, 17 | *, 18 | topic_arn: str, 19 | log_group_name: str, 20 | log_stream_name: str, 21 | raise_exceptions: bool = False 22 | ) -> None: 23 | super().__init__(level=WARNING) 24 | 25 | self._sns: Final[SNSClient] = boto_retry.get_client_with_standard_retry("sns") 26 | self._topic_arn: Final = topic_arn 27 | self._log_group: Final = log_group_name 28 | self._log_stream: Final = log_stream_name 29 | self._raise_exceptions: Final = raise_exceptions 30 | 31 | def emit(self, record: LogRecord) -> None: 32 | try: 33 | message: Final = "Loggroup: {}\nLogstream {}\n{} : {}".format( 34 | self._log_group, self._log_stream, record.levelname, record.getMessage() 35 | ) 36 | self._sns.publish(TopicArn=self._topic_arn, Message=message) 37 | except Exception: 38 | if self._raise_exceptions: 39 | raise 40 | -------------------------------------------------------------------------------- /source/app/instance_scheduler/util/time.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from datetime import datetime 4 | 5 | 6 | def is_aware(dt: datetime) -> bool: 7 | """ 8 | Returns `True` if the `datetime` is timezone-aware. 9 | 10 | [[Documentation] Determining if an Object is Aware or Naive](https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive) 11 | """ 12 | return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None 13 | -------------------------------------------------------------------------------- /source/app/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | 4 | [mypy-moto] 5 | ignore_missing_imports = True 6 | -------------------------------------------------------------------------------- /source/app/pyproject.toml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | [tool.poetry] 4 | name = "instance_scheduler" 5 | version = "3.0.10" 6 | description = "Instance Scheduler on AWS" 7 | license = "Apache-2.0" 8 | authors = [ "Amazon Web Services" ] 9 | homepage = "https://aws.amazon.com/solutions/implementations/instance-scheduler-on-aws/" 10 | readme = "README.md" 11 | 12 | [tool.poetry.dependencies] 13 | aws-lambda-powertools = "^3.4.1" 14 | packaging = "^24.0" 15 | python = "^3.11" 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | black = "^24.3.0" 19 | boto3 = "^1.34.1" 20 | botocore-stubs = "^1.31.66" 21 | botocore = "^1.34.1" 22 | flake8 = "^6.1.0" 23 | freezegun = "^1.3.1" 24 | isort = "^5.12.0" 25 | jmespath = "1.0.1" 26 | mypy = "^1.7.1" 27 | pytest-cov = "^4.1.0" 28 | pytest-mock = "^3.12.0" 29 | pytest-runner = "^6.0.1" 30 | pytest-xdist = "^3.5.0" 31 | pytest = "^7.4.3" 32 | python-dateutil = "2.8.2" 33 | tox = "^4.11.4" 34 | types-freezegun = "^1.1.10" 35 | types-jmespath = "1.0.1" 36 | types-python-dateutil = "2.8.2" 37 | types-requests = "2.31.0.6" 38 | types-urllib3 = "^1.26.15" 39 | tzdata = "^2023.3" 40 | urllib3 = "^1.26.15" 41 | 42 | [tool.poetry.group.dev.dependencies.boto3-stubs-lite] 43 | version = "^1.34.1" 44 | extras = [ 45 | "autoscaling", 46 | "cloudwatch", 47 | "dynamodb", 48 | "ec2", 49 | "ecs", 50 | "lambda", 51 | "logs", 52 | "rds", 53 | "resourcegroupstaggingapi", 54 | "sns", 55 | "ssm", 56 | "sts" 57 | ] 58 | 59 | [tool.poetry.group.dev.dependencies.moto] 60 | version = "^5.1.4" 61 | extras = [ 62 | "autoscaling", 63 | "dynamodb", 64 | "ec2", 65 | "logs", 66 | "rds", 67 | "resourcegroupstaggingapi", 68 | "ssm" 69 | ] 70 | 71 | [build-system] 72 | requires = [ "poetry-core" ] 73 | build-backend = "poetry.core.masonry.api" 74 | -------------------------------------------------------------------------------- /source/app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Final as _Final 4 | 5 | DEFAULT_REGION: _Final = "us-east-1" 6 | -------------------------------------------------------------------------------- /source/app/tests/boto_retry/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/boto_retry/test_boto_retry_init.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import TYPE_CHECKING 4 | 5 | from moto import mock_aws 6 | 7 | from instance_scheduler.boto_retry import get_client_with_standard_retry 8 | 9 | if TYPE_CHECKING: 10 | from mypy_boto3_ec2.client import EC2Client 11 | else: 12 | EC2Client = object 13 | 14 | 15 | def test_get_client_with_standard_retry() -> None: 16 | with mock_aws(): 17 | client: EC2Client = get_client_with_standard_retry("ec2") 18 | assert client.describe_instances()["Reservations"] == [] 19 | -------------------------------------------------------------------------------- /source/app/tests/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/configuration/test_time_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import pytest 4 | 5 | from instance_scheduler.configuration.time_utils import is_valid_time_str 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "time_str", ["00:00", "1:00", "01:00", "10:00", "00:05", "00:15", "23:59"] 10 | ) 11 | def test_valid_time_str(time_str: str) -> None: 12 | assert is_valid_time_str(time_str) is True 13 | 14 | 15 | @pytest.mark.parametrize("time_str", ["abc", "10:5", "1:5", "24:00", "25:00"]) 16 | def test_invalid_time_str(time_str: str) -> None: 17 | assert is_valid_time_str(time_str) is False 18 | -------------------------------------------------------------------------------- /source/app/tests/context.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from aws_lambda_powertools.utilities.typing import LambdaContext 4 | 5 | 6 | class MockLambdaContext(LambdaContext): 7 | def __init__(self, log_group_name: str = "logGroupName") -> None: 8 | self._aws_request_id = "requestId" 9 | self._log_group_name = log_group_name 10 | self._log_stream_name = "logStreamName" 11 | self._function_name = "functionName" 12 | self._memory_limit_in_mb = 128 13 | self._function_version = "$LATEST" 14 | self._invoked_function_arn = ( 15 | "arn:aws:lambda:us-east-1:123456789012:function:functionName" 16 | ) 17 | 18 | @staticmethod 19 | def get_remaining_time_in_millis() -> int: 20 | return 2000 21 | -------------------------------------------------------------------------------- /source/app/tests/cron/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/handler/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/handler/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from pytest import fixture 4 | 5 | 6 | @fixture(autouse=True) 7 | def auto_setup_log_group(mock_log_group: None) -> None: 8 | """noop""" 9 | 10 | 11 | @fixture(autouse=True) 12 | def auto_setup_sns_error_reporting_topic(mock_sns_errors_topic: None) -> None: 13 | """noop""" 14 | -------------------------------------------------------------------------------- /source/app/tests/handler/test_asg.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from datetime import datetime, timezone 5 | from os import environ 6 | from typing import Final 7 | from unittest.mock import MagicMock, patch 8 | from uuid import UUID 9 | 10 | from freezegun import freeze_time 11 | 12 | from instance_scheduler.handler.asg import ( 13 | ASG_SERVICE, 14 | AsgMetricsDefinition, 15 | send_operational_metrics, 16 | ) 17 | from tests import DEFAULT_REGION 18 | 19 | 20 | @patch("instance_scheduler.handler.asg.collect_metric") 21 | def test_send_operational_metrics(mock_collect_metric: MagicMock) -> None: 22 | # Prepare 23 | metric_hour: Final = UUID(environ["METRICS_UUID"]).int % 24 24 | dt: Final = datetime( 25 | year=2024, month=4, day=23, hour=metric_hour, minute=14, tzinfo=timezone.utc 26 | ) 27 | num_tagged_auto_scaling_groups = 4 28 | num_schedules = 1 29 | 30 | # Call 31 | with freeze_time(dt): 32 | send_operational_metrics( 33 | AsgMetricsDefinition( 34 | region=DEFAULT_REGION, 35 | num_tagged_auto_scaling_groups=num_tagged_auto_scaling_groups, 36 | num_schedules=num_schedules, 37 | ) 38 | ) 39 | 40 | # Verify 41 | assert mock_collect_metric.call_count == 1 42 | 43 | instance_count_metric = mock_collect_metric.call_args[1].get("metric") 44 | assert instance_count_metric.service == ASG_SERVICE 45 | assert instance_count_metric.region == DEFAULT_REGION 46 | assert instance_count_metric.num_instances == num_tagged_auto_scaling_groups 47 | assert instance_count_metric.num_schedules == num_schedules 48 | 49 | 50 | @patch("instance_scheduler.handler.asg.collect_metric") 51 | def test_not_send_operational_metrics_when_not_time_to_send( 52 | mock_collect_metric: MagicMock, 53 | ) -> None: 54 | # Prepare 55 | metric_hour: Final = UUID(environ["METRICS_UUID"]).int % 24 56 | current_hour = (metric_hour + 1) % 24 57 | dt: Final = datetime( 58 | year=2024, month=4, day=23, hour=current_hour, minute=14, tzinfo=timezone.utc 59 | ) 60 | num_tagged_auto_scaling_groups = 4 61 | num_schedules = 1 62 | 63 | # Call 64 | with freeze_time(dt): 65 | send_operational_metrics( 66 | AsgMetricsDefinition( 67 | region=DEFAULT_REGION, 68 | num_tagged_auto_scaling_groups=num_tagged_auto_scaling_groups, 69 | num_schedules=num_schedules, 70 | ) 71 | ) 72 | 73 | # Verify 74 | assert mock_collect_metric.call_count == 0 75 | -------------------------------------------------------------------------------- /source/app/tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | # this file is necessary for mypy to be able to correctly differentiate between conftest.py files 5 | -------------------------------------------------------------------------------- /source/app/tests/integration/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/integration/helpers/boto_client_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Any 4 | 5 | import boto3 6 | from mypy_boto3_sts import STSClient 7 | 8 | 9 | def client_in_account_region( 10 | client: str, account: str, region: str = "us-east-1" 11 | ) -> Any: 12 | sts: STSClient = boto3.client("sts") 13 | token = sts.assume_role( 14 | RoleArn=f"arn:aws:iam::{account}:role/moto-role", 15 | RoleSessionName="create-instances-session", 16 | ExternalId="create-instances-external-id", 17 | )["Credentials"] 18 | 19 | return boto3.client( 20 | client, 21 | aws_access_key_id=token["AccessKeyId"], 22 | aws_secret_access_key=token["SecretAccessKey"], 23 | aws_session_token=token["SessionToken"], 24 | region_name=region, 25 | ) 26 | -------------------------------------------------------------------------------- /source/app/tests/integration/helpers/schedule_helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import datetime 4 | 5 | 6 | def at_time( 7 | time: datetime.time, date: datetime.date = datetime.date(2023, 5, 31) 8 | ) -> datetime.datetime: 9 | return datetime.datetime.combine(date, time, datetime.timezone.utc) 10 | 11 | 12 | def quick_time(hrs: int, minutes: int, seconds: int = 0) -> datetime.datetime: 13 | return at_time(datetime.time(hrs, minutes, seconds)) 14 | -------------------------------------------------------------------------------- /source/app/tests/integration/helpers/scheduling_context_builder.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from datetime import datetime, time 4 | from zoneinfo import ZoneInfo 5 | 6 | from instance_scheduler.configuration.instance_schedule import InstanceSchedule 7 | from instance_scheduler.configuration.running_period import RunningPeriod 8 | from instance_scheduler.configuration.running_period_dict_element import ( 9 | RunningPeriodDictElement, 10 | ) 11 | from instance_scheduler.configuration.scheduling_context import SchedulingContext 12 | 13 | 14 | def default_test_schedules() -> dict[str, InstanceSchedule]: 15 | schedule = default_test_schedule() 16 | return {schedule.name: schedule} 17 | 18 | 19 | def default_test_schedule() -> InstanceSchedule: 20 | return InstanceSchedule( 21 | name="test-schedule", 22 | timezone=ZoneInfo("UTC"), 23 | periods=default_test_periods(), 24 | ) 25 | 26 | 27 | def default_test_periods() -> list[RunningPeriodDictElement]: 28 | return [ 29 | { 30 | "period": RunningPeriod( 31 | name="test-period", 32 | begintime=time(10, 0, 0), 33 | endtime=time(20, 0, 0), 34 | ) 35 | } 36 | ] 37 | 38 | 39 | def build_scheduling_context( 40 | current_dt: datetime, 41 | schedules: dict[str, InstanceSchedule] = None, # type: ignore[assignment] 42 | account_id: str = "123456789012", 43 | service: str = "ec2", 44 | region: str = "us-east-1", 45 | default_timezone: str = "UTC", 46 | scheduling_interval_minutes: int = 5, 47 | ) -> SchedulingContext: 48 | """abstraction layer on SchedulingContextConstructor that provides testing defaults for most values""" 49 | if schedules is None: 50 | schedules = default_test_schedules() 51 | 52 | return SchedulingContext( 53 | current_dt=current_dt, 54 | schedules=schedules, 55 | account_id=account_id, 56 | service=service, 57 | region=region, 58 | default_timezone=ZoneInfo(default_timezone), 59 | scheduling_interval_minutes=scheduling_interval_minutes, 60 | ) 61 | -------------------------------------------------------------------------------- /source/app/tests/integration/ops_metrics/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/integration/ops_metrics/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from contextlib import contextmanager 4 | from typing import Iterator 5 | from unittest.mock import MagicMock, patch 6 | 7 | from _pytest.fixtures import fixture 8 | from urllib3 import HTTPResponse 9 | 10 | 11 | @contextmanager 12 | def override_should_send_metric(return_value: bool) -> Iterator[None]: 13 | with patch( 14 | "instance_scheduler.ops_metrics.metrics.should_collect_metric" 15 | ) as should_collect_metrics_func: 16 | should_collect_metrics_func.return_value = return_value 17 | yield 18 | 19 | 20 | @fixture 21 | def mock_metrics_endpoint() -> Iterator[MagicMock]: 22 | with patch( 23 | "instance_scheduler.ops_metrics.metrics.http.request" 24 | ) as post_request_func: 25 | post_response = HTTPResponse(status=200) 26 | post_request_func.return_value = post_response 27 | 28 | yield post_request_func 29 | -------------------------------------------------------------------------------- /source/app/tests/integration/ops_metrics/test_cli_metrics.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import datetime 4 | import json 5 | from unittest.mock import MagicMock 6 | from zoneinfo import ZoneInfo 7 | 8 | from freezegun.api import freeze_time 9 | 10 | from instance_scheduler import __version__ 11 | from instance_scheduler.handler.cli.cli_request_handler import CliRequestHandler 12 | from tests.context import MockLambdaContext 13 | from tests.logger import MockLogger 14 | from tests.test_utils.mock_main_lambda_env import MockMainLambdaEnv 15 | from tests.test_utils.mock_metrics_environment import MockMetricsEnviron 16 | 17 | 18 | @freeze_time(datetime.datetime(2023, 6, 12, 12, 0, 0, tzinfo=ZoneInfo("UTC"))) 19 | def test_cli_handler_sends_expected_metric(mock_metrics_endpoint: MagicMock) -> None: 20 | with MockMetricsEnviron(send_anonymous_metrics=True) as metrics_environment: 21 | action = "my-action" 22 | parameters = {"my_key": "my-value"} 23 | handler = CliRequestHandler( 24 | { 25 | "action": action, 26 | "parameters": parameters, 27 | "version": __version__, 28 | }, 29 | MockLambdaContext(), 30 | MockMainLambdaEnv(), 31 | ) 32 | handler._logger = MockLogger() 33 | handler.handle_request() 34 | 35 | expected_metric = { 36 | "timestamp": "2023-06-12 12:00:00", 37 | "uuid": str(metrics_environment.metrics_uuid), 38 | "solution": metrics_environment.solution_id, 39 | "version": metrics_environment.solution_version, 40 | "event_name": "cli_request", 41 | "context_version": 1, 42 | "context": {"command_used": action}, 43 | } 44 | 45 | assert mock_metrics_endpoint.call_count == 1 46 | json_payload = mock_metrics_endpoint.call_args[1]["body"] 47 | assert json.loads(json_payload) == expected_metric 48 | -------------------------------------------------------------------------------- /source/app/tests/integration/test_ec2_schedule_retry.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from unittest.mock import Mock, patch 4 | 5 | import boto3 6 | 7 | from instance_scheduler.schedulers.instance_states import InstanceStates 8 | from tests.integration.helpers.ec2_helpers import get_current_state, stop_ec2_instances 9 | from tests.integration.helpers.run_handler import simple_schedule 10 | from tests.integration.helpers.schedule_helpers import quick_time 11 | 12 | 13 | def willRaise(ex: Exception) -> None: 14 | raise ex 15 | 16 | 17 | # https://docs.getmoto.org/en/latest/docs/services/patching_other_services.html 18 | 19 | 20 | def test_ec2_starts_at_beginning_of_period( 21 | ec2_instance: str, 22 | ec2_instance_states: InstanceStates, 23 | ) -> None: 24 | # instance is already stopped 25 | stop_ec2_instances(ec2_instance) 26 | with simple_schedule(begintime="10:00", endtime="20:00") as context: 27 | # before start of period (populates state table) 28 | context.run_scheduling_request_handler(dt=quick_time(9, 55)) 29 | assert get_current_state(ec2_instance) == "stopped" 30 | 31 | client_mock = Mock(wraps=boto3.client("ec2")) 32 | with patch( 33 | "instance_scheduler.util.session_manager.AssumedRole.client", 34 | lambda x, y: client_mock, 35 | ): 36 | client_mock.start_instances = lambda x, y: willRaise( 37 | Exception("start failure") 38 | ) 39 | 40 | # start fails at beginning of period 41 | context.run_scheduling_request_handler(dt=quick_time(10, 0)) 42 | assert get_current_state(ec2_instance) == "stopped" 43 | 44 | # retry 45 | context.run_scheduling_request_handler(dt=quick_time(10, 5)) 46 | assert get_current_state(ec2_instance) == "running" 47 | -------------------------------------------------------------------------------- /source/app/tests/integration/test_nth_weekday_scheduling.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from datetime import datetime 4 | 5 | import pytest 6 | from dateutil.tz import tzutc 7 | 8 | from instance_scheduler.schedulers.instance_states import InstanceStates 9 | from tests.integration.helpers.ec2_helpers import get_current_state 10 | from tests.integration.helpers.run_handler import simple_schedule 11 | 12 | # using september 2024 which starts on a Sunday 13 | # 01 02 03 04 05 06 07 14 | # 08 09 10 11 12 13 14 15 | # 15 16 17 18 19 20 21 16 | # 22 23 24 25 26 27 28 17 | # 29 30 01 18 | 19 | 20 | @pytest.mark.skip( 21 | "long-running test (about 60second by itself) so skipping by default. " 22 | "this can be run manually as an e2e test over this behavior while the individual " 23 | "pieces also have more fine grained unit tests elsewhere" 24 | ) 25 | @pytest.mark.parametrize( 26 | "weekdayExpr, dayRunning", 27 | [ 28 | ("Sun#1", 1), 29 | ("Mon#1", 2), 30 | ("Mon#3", 16), 31 | ("Fri#4", 27), 32 | ("Tue#5", 30), 33 | ], 34 | ) 35 | def test_nth_weekday_scheduling( 36 | weekdayExpr: str, 37 | dayRunning: int, 38 | ec2_instance: str, 39 | ec2_instance_states: InstanceStates, 40 | ) -> None: 41 | 42 | with simple_schedule(weekdays={weekdayExpr}) as context: 43 | for day in range(1, 30): 44 | context.run_scheduling_request_handler( 45 | datetime(2024, 9, day, 12, tzinfo=tzutc()) 46 | ) 47 | assert ( 48 | get_current_state(ec2_instance) == "running" 49 | if day == dayRunning 50 | else "stopped" 51 | ), f"{get_current_state(ec2_instance)} on {day} when should be {'running' if day == dayRunning else 'stopped'}" 52 | -------------------------------------------------------------------------------- /source/app/tests/integration/test_rds_cluster_instance.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import TYPE_CHECKING 4 | 5 | import boto3 6 | from mypy_boto3_rds.type_defs import DBClusterMemberTypeDef 7 | 8 | from instance_scheduler.schedulers.instance_states import InstanceStates 9 | from tests.integration.helpers.rds_helpers import ( 10 | get_rds_cluster_state, 11 | get_rds_instance_state, 12 | ) 13 | from tests.integration.helpers.run_handler import simple_schedule, target 14 | from tests.integration.helpers.schedule_helpers import quick_time 15 | 16 | if TYPE_CHECKING: 17 | from mypy_boto3_rds.client import RDSClient 18 | else: 19 | RDSClient = object 20 | 21 | 22 | def tag_rds_instance( 23 | instance: DBClusterMemberTypeDef, schedule_name: str, rds_client: RDSClient 24 | ) -> None: 25 | instance_description = rds_client.describe_db_instances( 26 | DBInstanceIdentifier=instance["DBInstanceIdentifier"] 27 | ) 28 | arn = instance_description["DBInstances"][0]["DBInstanceArn"] 29 | rds_client.add_tags_to_resource( 30 | ResourceName=arn, Tags=[{"Key": "Schedule", "Value": schedule_name}] 31 | ) 32 | 33 | 34 | def test_rds_cluster_instances_are_not_scheduled_individually( 35 | rds_cluster: str, 36 | rds_instance_states: InstanceStates, 37 | ) -> None: 38 | rds_client: RDSClient = boto3.client("rds") 39 | 40 | cluster = rds_client.describe_db_clusters(DBClusterIdentifier=rds_cluster) 41 | instances = [instance for instance in cluster["DBClusters"][0]["DBClusterMembers"]] 42 | 43 | assert len(instances) > 0 # test would be invalid if there were no instances 44 | 45 | # customer incorrectly tags instances that are members of a cluster 46 | for instance in instances: 47 | tag_rds_instance(instance, "some-other-schedule", rds_client) 48 | 49 | with simple_schedule( 50 | name="some-other-schedule", begintime="10:00", endtime="20:00" 51 | ) as context: 52 | # within period (populate state table) 53 | context.run_scheduling_request_handler( 54 | dt=quick_time(19, 55), target=target(service="rds") 55 | ) 56 | 57 | # period end (would normally stop) 58 | context.run_scheduling_request_handler( 59 | dt=quick_time(20, 0), target=target(service="rds") 60 | ) 61 | 62 | # neither the instances nor the cluster should have been stopped 63 | # (the cluster is tagged with a different schedule) 64 | assert get_rds_cluster_state(rds_cluster) == "available" 65 | for instance in instances: 66 | assert ( 67 | get_rds_instance_state(instance["DBInstanceIdentifier"]) == "available" 68 | ) 69 | -------------------------------------------------------------------------------- /source/app/tests/integration/test_rds_cluster_scheduling.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | from instance_scheduler.handler.environments.main_lambda_environment import ( 5 | MainLambdaEnv, 6 | ) 7 | from instance_scheduler.schedulers.instance_states import InstanceStates 8 | from tests.integration.helpers.rds_helpers import ( 9 | get_rds_cluster_state, 10 | stop_rds_clusters, 11 | ) 12 | from tests.integration.helpers.run_handler import simple_schedule, target 13 | from tests.integration.helpers.schedule_helpers import quick_time 14 | 15 | 16 | def test_rds_cluster_starts_at_beginning_of_period( 17 | rds_cluster: str, 18 | rds_instance_states: InstanceStates, 19 | ) -> None: 20 | with simple_schedule(begintime="10:00", endtime="20:00") as context: 21 | stop_rds_clusters(rds_cluster) 22 | 23 | # before start of period (populates state table) 24 | context.run_scheduling_request_handler( 25 | dt=quick_time(9, 55), target=target(service="rds") 26 | ) 27 | assert get_rds_cluster_state(rds_cluster) == "stopped" 28 | 29 | # start of period 30 | context.run_scheduling_request_handler( 31 | dt=quick_time(10, 0), target=target(service="rds") 32 | ) 33 | assert get_rds_cluster_state(rds_cluster) == "available" 34 | 35 | 36 | def test_rds_cluster_stops_at_end_of_period( 37 | rds_cluster: str, 38 | rds_instance_states: InstanceStates, 39 | test_suite_env: MainLambdaEnv, 40 | ) -> None: 41 | with simple_schedule(begintime="10:00", endtime="20:00") as context: 42 | # before end of period (populates state table) 43 | context.run_scheduling_request_handler( 44 | dt=quick_time(19, 55), target=target(service="rds") 45 | ) 46 | assert get_rds_cluster_state(rds_cluster) == "available" 47 | 48 | # end of period 49 | context.run_scheduling_request_handler( 50 | dt=quick_time(20, 0), target=target(service="rds") 51 | ) 52 | assert get_rds_cluster_state(rds_cluster) == "stopped" 53 | -------------------------------------------------------------------------------- /source/app/tests/integration/test_stop_new_instances_flag.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from instance_scheduler.schedulers.instance_states import InstanceStates 4 | from instance_scheduler.schedulers.states import InstanceState 5 | from tests.integration.helpers.ec2_helpers import get_current_state 6 | from tests.integration.helpers.run_handler import simple_schedule 7 | from tests.integration.helpers.schedule_helpers import quick_time 8 | 9 | 10 | def test_new_instance_stops_when_outside_period_and_flag_is_set( 11 | ec2_instance: str, 12 | ec2_instance_states: InstanceStates, 13 | ) -> None: 14 | with simple_schedule( 15 | begintime="10:00", endtime="20:00", stop_new_instances=True 16 | ) as context: 17 | assert get_current_state(ec2_instance) == "running" 18 | context.run_scheduling_request_handler(dt=quick_time(5, 0)) 19 | assert get_current_state(ec2_instance) == "stopped" 20 | 21 | 22 | def test_instance_does_not_stop_when_it_is_not_new( 23 | ec2_instance: str, 24 | ec2_instance_states: InstanceStates, 25 | ) -> None: 26 | with simple_schedule( 27 | begintime="10:00", endtime="20:00", stop_new_instances=True 28 | ) as context: 29 | # already registered in state table 30 | ec2_instance_states.set_instance_state(ec2_instance, InstanceState.STOPPED) 31 | ec2_instance_states.save() 32 | 33 | assert get_current_state(ec2_instance) == "running" 34 | context.run_scheduling_request_handler(dt=quick_time(5, 0)) 35 | assert get_current_state(ec2_instance) == "running" 36 | 37 | 38 | def test_new_instance_does_not_stop_when_outside_period_and_flag_is_not_set( 39 | ec2_instance: str, 40 | ec2_instance_states: InstanceStates, 41 | ) -> None: 42 | with simple_schedule( 43 | begintime="10:00", endtime="20:00", stop_new_instances=False 44 | ) as context: 45 | assert get_current_state(ec2_instance) == "running" 46 | context.run_scheduling_request_handler(dt=quick_time(5, 0)) 47 | assert get_current_state(ec2_instance) == "running" 48 | 49 | 50 | def test_new_instance_does_not_stop_when_inside_period_and_flag_is_set( 51 | ec2_instance: str, 52 | ec2_instance_states: InstanceStates, 53 | ) -> None: 54 | with simple_schedule( 55 | begintime="10:00", endtime="20:00", stop_new_instances=True 56 | ) as context: 57 | assert get_current_state(ec2_instance) == "running" 58 | context.run_scheduling_request_handler(dt=quick_time(15, 0)) 59 | assert get_current_state(ec2_instance) == "running" 60 | -------------------------------------------------------------------------------- /source/app/tests/logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Any 4 | 5 | from instance_scheduler.util.logger import Logger 6 | 7 | 8 | class MockLogger(Logger): 9 | def __init__( 10 | self, 11 | log_group: str = "", 12 | log_stream: str = "", 13 | topic_arn: str = "", 14 | debug: bool = False, 15 | ) -> None: 16 | Logger.__init__(self, log_group="", log_stream="", topic_arn="") 17 | 18 | def info(self, msg: str, *args: Any) -> None: 19 | s = msg if len(args) == 0 else msg.format(*args) 20 | print(f"info: {s}") 21 | 22 | def error(self, msg: str, *args: Any) -> None: 23 | s = msg if len(args) == 0 else msg.format(*args) 24 | print(f"error: {s}") 25 | 26 | def warning(self, msg: str, *args: Any) -> None: 27 | s = msg if len(args) == 0 else msg.format(*args) 28 | print(f"warning: {s}") 29 | 30 | def debug(self, msg: str, *args: Any) -> None: 31 | s = msg if len(args) == 0 else msg.format(*args) 32 | print(f"debug: {s}") 33 | 34 | def flush(self) -> None: 35 | """noop""" 36 | -------------------------------------------------------------------------------- /source/app/tests/maint_win/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/maint_win/test_ssm_mw_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from datetime import datetime, timedelta 4 | from typing import Final 5 | from zoneinfo import ZoneInfo 6 | 7 | from freezegun import freeze_time 8 | 9 | from instance_scheduler.model.maint_win import EC2SSMMaintenanceWindow 10 | 11 | # use common UTC zoneinfo in these tests to allow proper emulation of SSM API 12 | utc_timezone: Final = ZoneInfo("Etc/UTC") 13 | account_id: Final = "123456789012" 14 | region: Final = "us-east-1" 15 | 16 | 17 | def test_window_currently_running() -> None: 18 | next_execution_time: Final = datetime( 19 | year=2023, month=11, day=6, hour=15, minute=14, tzinfo=utc_timezone 20 | ) 21 | duration_hours: Final = 1 22 | 23 | window: Final = EC2SSMMaintenanceWindow( 24 | account_id="111111111111", 25 | region="us-east-1", 26 | schedule_timezone=utc_timezone, 27 | window_id="mw-00000000000000000", 28 | window_name="mon-1", 29 | duration_hours=duration_hours, 30 | next_execution_time=next_execution_time, 31 | ) 32 | 33 | end: Final = next_execution_time + timedelta(hours=duration_hours) 34 | 35 | for dt in ( 36 | next_execution_time - timedelta(minutes=20), 37 | end, 38 | end + timedelta(minutes=1), 39 | ): 40 | with freeze_time(dt): 41 | assert not window.is_running_at(dt, scheduler_interval_minutes=5) 42 | for dt in ( 43 | next_execution_time, 44 | next_execution_time - timedelta(minutes=10), # 10 minute early start 45 | next_execution_time + timedelta(minutes=1), 46 | end - timedelta(minutes=1), 47 | ): 48 | with freeze_time(dt): 49 | assert window.is_running_at(dt, scheduler_interval_minutes=5) 50 | -------------------------------------------------------------------------------- /source/app/tests/model/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/model/store/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/instance-scheduler-on-aws/9451a826c03c64b09280b9172f9911ec1f31a4cb/source/app/tests/model/store/__init__.py -------------------------------------------------------------------------------- /source/app/tests/model/store/test_in_memory_period_definition_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import pytest 4 | from _pytest.fixtures import fixture 5 | 6 | from instance_scheduler.model.period_definition import PeriodDefinition, PeriodParams 7 | from instance_scheduler.model.store.in_memory_period_definition_store import ( 8 | InMemoryPeriodDefinitionStore, 9 | ) 10 | from instance_scheduler.util.validation import ValidationException 11 | 12 | 13 | @fixture 14 | def period_store() -> InMemoryPeriodDefinitionStore: 15 | return InMemoryPeriodDefinitionStore() 16 | 17 | 18 | def test_serialize_then_deserialize( 19 | period_store: InMemoryPeriodDefinitionStore, 20 | ) -> None: 21 | period_store.put(PeriodDefinition("period1", begintime="05:00", endtime="10:00")) 22 | period_store.put(PeriodDefinition("period2", weekdays={"Mon-Fri"})) 23 | period_store.put(PeriodDefinition("period3", monthdays={"1-5"})) 24 | period_store.put(PeriodDefinition("period4", months={"Jan-Feb"})) 25 | 26 | serial_data = period_store.serialize() 27 | 28 | # ensure returned data matches own validation 29 | period_store.validate_serial_data(serial_data) 30 | 31 | deserialized_store = InMemoryPeriodDefinitionStore.deserialize(serial_data) 32 | 33 | assert deserialized_store.find_all() == period_store.find_all() 34 | 35 | 36 | def test_validate_rejects_malformed_input() -> None: 37 | with pytest.raises(ValidationException): 38 | # not a sequence 39 | InMemoryPeriodDefinitionStore.validate_serial_data({}) 40 | 41 | with pytest.raises(ValidationException): 42 | # contained data is not a dict 43 | InMemoryPeriodDefinitionStore.validate_serial_data(["something-invalid"]) 44 | 45 | with pytest.raises(ValidationException): 46 | # contained data is not valid PeriodParams 47 | InMemoryPeriodDefinitionStore.validate_serial_data( 48 | [PeriodParams(name="aPeriod"), {"invalid-key": "something-invalid"}] 49 | ) 50 | -------------------------------------------------------------------------------- /source/app/tests/model/store/test_maint_win_store.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from datetime import datetime, timezone 4 | from typing import Final 5 | from zoneinfo import ZoneInfo 6 | 7 | from instance_scheduler.model import ( 8 | EC2SSMMaintenanceWindow, 9 | EC2SSMMaintenanceWindowStore, 10 | ) 11 | 12 | 13 | def test_maint_win_store(maint_win_table: str) -> None: 14 | account_id = "111111111111" 15 | region = "us-east-1" 16 | window_name = "my-window" 17 | next_execution_time = datetime(year=2023, month=6, day=23, tzinfo=timezone.utc) 18 | duration = 1 19 | window_id = "mw-00000000000000000" 20 | schedule_timezone = "UTC" 21 | window: Final = EC2SSMMaintenanceWindow( 22 | account_id=account_id, 23 | region=region, 24 | window_id=window_id, 25 | window_name=window_name, 26 | schedule_timezone=ZoneInfo(schedule_timezone), 27 | next_execution_time=next_execution_time, 28 | duration_hours=duration, 29 | ) 30 | 31 | store = EC2SSMMaintenanceWindowStore(maint_win_table) 32 | 33 | windows = list(store.get_ssm_windows_db(account=account_id, region=region)) 34 | assert windows == [] 35 | 36 | store.put_window_dynamodb(window) 37 | windows = list(store.get_ssm_windows_db(account=account_id, region=region)) 38 | 39 | assert len(windows) == 1 40 | assert windows[0] == window 41 | 42 | store.delete_window(windows[0]) 43 | 44 | windows = list(store.get_ssm_windows_db(account=account_id, region=region)) 45 | assert windows == [] 46 | -------------------------------------------------------------------------------- /source/app/tests/model/test_period_identifier.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from instance_scheduler.model.period_identifier import PeriodIdentifier 4 | 5 | 6 | def test_simple_identifier() -> None: 7 | pid = PeriodIdentifier.of("period_name") 8 | assert pid == "period_name" 9 | assert pid.name == "period_name" 10 | 11 | 12 | def test_identifier_with_type() -> None: 13 | pid = PeriodIdentifier.of("period_name", "desired_type") 14 | assert pid == "period_name@desired_type" 15 | assert pid.name == "period_name" 16 | assert pid.desired_type == "desired_type" 17 | -------------------------------------------------------------------------------- /source/app/tests/ops_monitoring/test_instance_counts.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from collections import Counter 4 | 5 | from instance_scheduler.ops_monitoring.instance_counts import ( 6 | InstanceCounts, 7 | InstanceCountsAggregator, 8 | ServiceInstanceCounts, 9 | ) 10 | 11 | 12 | def test_merge_combines_as_expected() -> None: 13 | data1 = ServiceInstanceCounts( 14 | { 15 | "ec2": InstanceCountsAggregator( 16 | { 17 | "by_type": InstanceCounts( 18 | {"t2.micro": Counter({"running": 10, "stopped": 5})} 19 | ), 20 | "by_schedule": InstanceCounts( 21 | {"schedule_a": Counter({"running": 5, "stopped": 2})} 22 | ), 23 | }, 24 | ), 25 | "rds": InstanceCountsAggregator( 26 | { 27 | "by_type": InstanceCounts( 28 | {"t3.micro": Counter({"running": 2, "stopped": 2})} 29 | ), 30 | "by_schedule": InstanceCounts( 31 | {"schedule_b": Counter({"running": 2, "stopped": 1})} 32 | ), 33 | }, 34 | ), 35 | } 36 | ) 37 | 38 | data2 = ServiceInstanceCounts( 39 | { 40 | "ec2": InstanceCountsAggregator( 41 | { 42 | "by_type": InstanceCounts( 43 | { 44 | "t2.micro": Counter({"running": 5, "stopped": 3}), 45 | "t2.nano": Counter({"running": 4, "stopped": 2}), 46 | } 47 | ), 48 | "by_schedule": InstanceCounts( 49 | {"schedule_a": Counter({"running": 2, "stopped": 12})} 50 | ), 51 | }, 52 | ), 53 | } 54 | ) 55 | 56 | assert data1.merged_with(data2) == { 57 | "ec2": { 58 | "by_type": { 59 | "t2.micro": {"running": 15, "stopped": 8}, 60 | "t2.nano": {"running": 4, "stopped": 2}, 61 | }, 62 | "by_schedule": {"schedule_a": {"running": 7, "stopped": 14}}, 63 | }, 64 | "rds": { 65 | "by_type": {"t3.micro": {"running": 2, "stopped": 2}}, 66 | "by_schedule": {"schedule_b": {"running": 2, "stopped": 1}}, 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /source/app/tests/schedulers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/schedulers/test_instance_scheduler.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/instance-scheduler-on-aws/9451a826c03c64b09280b9172f9911ec1f31a4cb/source/app/tests/schedulers/test_instance_scheduler.py -------------------------------------------------------------------------------- /source/app/tests/service/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/service/test_asg_service.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from unittest.mock import MagicMock, patch 4 | 5 | from boto3 import Session 6 | 7 | from instance_scheduler.service.asg import AsgService 8 | from instance_scheduler.util.session_manager import AssumedRole 9 | from tests.test_utils.mock_asg_environment import MockAsgEnvironment 10 | 11 | 12 | def test_get_schedulable_groups_respects_5_tags_at_a_time_limit( 13 | moto_backend: None, 14 | ) -> None: 15 | env = MockAsgEnvironment() 16 | asg_service = AsgService( 17 | assumed_asg_scheduling_role=AssumedRole( 18 | account="123456789012", 19 | region="us-east-1", 20 | role_name="role-name", 21 | session=Session(), 22 | ), 23 | schedule_tag_key=env.schedule_tag_key, 24 | asg_scheduled_tag_key=env.scheduled_tag_key, 25 | rule_prefix=env.rule_prefix, 26 | ) 27 | 28 | with patch.object(asg_service, "_autoscaling") as mock_autoscaling_client: 29 | paginator = MagicMock() 30 | mock_autoscaling_client.get_paginator.return_value = paginator 31 | schedule_names = [ 32 | "a", 33 | "b", 34 | "c", 35 | "d", 36 | "e", 37 | "f", 38 | "g", 39 | "h", 40 | ] 41 | 42 | list(asg_service.get_schedulable_groups(schedule_names)) 43 | 44 | paginator.paginate.assert_called() 45 | requested_schedule_names = [] 46 | 47 | # assert that no reqeust exceeded 5 names at once 48 | for paginate_call in paginator.paginate.call_args_list: 49 | assert len(paginate_call.kwargs["Filters"][0]["Values"]) <= 5 50 | requested_schedule_names.extend( 51 | paginate_call.kwargs["Filters"][0]["Values"] 52 | ) 53 | 54 | # assert that all schedules were actually requested 55 | assert len(requested_schedule_names) == 8 56 | assert all( 57 | schedule_name in requested_schedule_names 58 | for schedule_name in schedule_names 59 | ) 60 | -------------------------------------------------------------------------------- /source/app/tests/test_enforce_headers.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from collections.abc import Iterator 4 | from os import scandir 5 | from pathlib import Path 6 | 7 | optional_shebang = "#!/usr/bin/env python" 8 | 9 | header_lines = [ 10 | "# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.", 11 | "# SPDX-License-Identifier: Apache-2.0", 12 | ] 13 | 14 | 15 | def valid_header(path: Path) -> bool: 16 | header_length = len(header_lines) 17 | lines = [] 18 | with open(path) as f: 19 | for _ in range(header_length + 1): 20 | lines.append(f.readline()) 21 | line_index = 0 22 | if lines[0].strip() == optional_shebang: 23 | line_index = 1 24 | for header_line in header_lines: 25 | if lines[line_index].strip() != header_line: 26 | return False 27 | line_index += 1 28 | return True 29 | 30 | 31 | exclude_dirs = {".tox", ".mypy_cache"} 32 | 33 | 34 | def python_source_files(path: str) -> Iterator[Path]: 35 | for file in Path(path).glob("*.py"): 36 | if file.stat().st_size > 0: 37 | yield file 38 | for entry in scandir(path): 39 | if entry.is_dir() and entry.name not in exclude_dirs: 40 | yield from python_source_files(entry.path) 41 | 42 | 43 | def test_headers_exist() -> None: 44 | for file_path in python_source_files("."): 45 | assert valid_header( 46 | file_path 47 | ), f"{file_path} does not contain a valid copyright header" 48 | -------------------------------------------------------------------------------- /source/app/tests/test_init.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from importlib.metadata import version 4 | 5 | from instance_scheduler import __version__ 6 | 7 | 8 | def test_version_read_from_toml_matches_package_version() -> None: 9 | assert version("instance_scheduler") == __version__ 10 | -------------------------------------------------------------------------------- /source/app/tests/test_main.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import os 4 | from unittest.mock import MagicMock, patch 5 | 6 | from aws_lambda_powertools.utilities.typing import LambdaContext 7 | 8 | from instance_scheduler import main 9 | from instance_scheduler.main import lambda_handler 10 | from instance_scheduler.util.logger import Logger 11 | 12 | 13 | @patch.object(Logger, "client") # stops logger from slowing down the test 14 | def test_correct_handler_called(logger_client: MagicMock) -> None: 15 | mock_handler = MagicMock() 16 | mock_handler.is_handling_request.return_value = True 17 | my_response = "Everything's great!" 18 | mock_handler.return_value.handle_request.return_value = my_response 19 | mock_handler.__name__ = "my-handler" 20 | 21 | with patch.dict( 22 | os.environ, 23 | { 24 | "LOG_GROUP": "log-group-name", 25 | "ISSUES_TOPIC_ARN": "arn", 26 | "SOLUTION_VERSION": "v9.9.9", 27 | "TRACE": "True", 28 | "USER_AGENT_EXTRA": "my-agent-extra", 29 | "ENABLE_AWS_ORGANIZATIONS": "False", 30 | "CONFIG_TABLE": "config-table-name", 31 | }, 32 | ): 33 | with patch.object(main, "handlers", (mock_handler,)): 34 | assert lambda_handler({}, LambdaContext()) == my_response 35 | 36 | mock_handler.return_value.handle_request.assert_called_once() 37 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/any_nonempty_string.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Any 4 | 5 | import pytest 6 | 7 | 8 | class AnyNonEmptyString(str): 9 | """helper object for asserting equals against any non-empty string value""" 10 | 11 | def __str__(self) -> str: 12 | return "##AnyNonEmptyString##" 13 | 14 | def __len__(self) -> int: 15 | return self.__str__().__len__() 16 | 17 | def __eq__(self, other: Any) -> bool: 18 | if not isinstance(other, str): 19 | return False 20 | return bool(other.strip()) 21 | 22 | def __ne__(self, other: Any) -> bool: 23 | return not self.__eq__(other) 24 | 25 | 26 | @pytest.mark.parametrize("valid_input", ["string", "false", "-1"]) 27 | def test_equals_non_empty_string(valid_input: str) -> None: 28 | assert valid_input == AnyNonEmptyString() 29 | assert AnyNonEmptyString() == valid_input 30 | 31 | 32 | @pytest.mark.parametrize("invalid_input", ["", " ", None]) 33 | def test_not_equals_empty_string(invalid_input: str) -> None: 34 | assert invalid_input != AnyNonEmptyString() 35 | assert AnyNonEmptyString() != invalid_input 36 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/mock_asg_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import os 4 | from dataclasses import dataclass 5 | from unittest.mock import patch 6 | from zoneinfo import ZoneInfo 7 | 8 | 9 | @dataclass 10 | class MockAsgEnvironment: 11 | user_agent_extra: str = "my-user-agent-extra" 12 | issues_topic_arn: str = "arn:aws:sns:us-east-1:123456789012:my-topic-arn" 13 | logger_raise_exceptions: bool = False 14 | config_table_name: str = "my-config-table-name" 15 | asg_scheduling_role_name: str = "my-role" 16 | default_timezone: ZoneInfo = ZoneInfo("UTC") 17 | schedule_tag_key: str = "Schedule" 18 | scheduled_tag_key: str = "scheduled" 19 | rule_prefix: str = "is-" 20 | 21 | def _to_env_dict(self) -> dict[str, str]: 22 | return { 23 | "USER_AGENT_EXTRA": self.user_agent_extra, 24 | "ISSUES_TOPIC_ARN": self.issues_topic_arn, 25 | "LOGGER_RAISE_EXCEPTIONS": str(self.logger_raise_exceptions), 26 | "CONFIG_TABLE": self.config_table_name, 27 | "ASG_SCHEDULING_ROLE_NAME": self.asg_scheduling_role_name, 28 | "DEFAULT_TIMEZONE": str(self.default_timezone), 29 | "SCHEDULE_TAG_KEY": self.schedule_tag_key, 30 | "SCHEDULED_TAG_KEY": self.scheduled_tag_key, 31 | "RULE_PREFIX": self.rule_prefix, 32 | } 33 | 34 | def __enter__(self) -> "MockAsgEnvironment": 35 | self._patcher = patch.dict(os.environ, self._to_env_dict()) 36 | self._patcher.__enter__() 37 | return self 38 | 39 | def __exit__(self, exc_type: None, exc_val: None, exc_tb: None) -> None: 40 | self._patcher.__exit__() 41 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/mock_asg_orchestrator_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass, field 4 | 5 | from instance_scheduler.handler.environments.asg_orch_env import AsgOrchEnv 6 | 7 | 8 | @dataclass(frozen=True) 9 | class MockAsgOrchestratorEnvironment(AsgOrchEnv): 10 | user_agent_extra: str = "my-user-agent-extra" 11 | 12 | issues_topic_arn: str = "arn:aws:sns:us-east-1:123456789012:my-topic-arn" 13 | logger_raise_exceptions: bool = False 14 | 15 | config_table_name: str = "my-config-table-name" 16 | enable_schedule_hub_account: bool = True 17 | schedule_regions: list[str] = field(default_factory=list) 18 | asg_scheduler_name: str = "asg-scheduling-request-handler-lambda" 19 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/mock_main_lambda_env.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass 4 | 5 | from instance_scheduler.handler.environments.main_lambda_environment import ( 6 | MainLambdaEnv, 7 | ) 8 | 9 | 10 | @dataclass(frozen=True) 11 | class MockMainLambdaEnv(MainLambdaEnv): 12 | log_group: str = "my-log-group" 13 | topic_arn: str = "arn:aws:sns:us-east-1:123456789012:my-topic-arn" 14 | solution_version: str = "v9.9.9" 15 | enable_debug_logging: bool = True 16 | user_agent_extra: str = "my-user-agent-extra" 17 | enable_aws_organizations: bool = False 18 | config_table_name: str = "my-config-table-name" 19 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/mock_metrics_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import os 4 | import uuid 5 | from dataclasses import dataclass 6 | from unittest.mock import patch 7 | 8 | from instance_scheduler.ops_metrics import metrics 9 | from instance_scheduler.ops_metrics.metrics import MetricsEnvironment 10 | 11 | 12 | @dataclass 13 | class MockMetricsEnviron(MetricsEnvironment): 14 | send_anonymous_metrics: bool = False 15 | anonymous_metrics_url: str = "my-metrics-url" 16 | solution_id: str = "my-solution-id" 17 | solution_version: str = "my-solution-version" 18 | scheduler_frequency_minutes: int = 5 19 | metrics_uuid: uuid.UUID = uuid.uuid4() 20 | 21 | def _to_env_dict(self) -> dict[str, str]: 22 | return { 23 | "SEND_METRICS": str(self.send_anonymous_metrics), 24 | "METRICS_URL": self.anonymous_metrics_url, 25 | "SOLUTION_ID": self.solution_id, 26 | "SOLUTION_VERSION": self.solution_version, 27 | "SCHEDULING_INTERVAL_MINUTES": str(self.scheduler_frequency_minutes), 28 | "METRICS_UUID": str(self.metrics_uuid), 29 | } 30 | 31 | def __enter__(self) -> "MockMetricsEnviron": 32 | self._patcher = patch.dict(os.environ, self._to_env_dict()) 33 | self._patcher.__enter__() 34 | metrics._metrics_env = None # reset caching 35 | return self 36 | 37 | def __exit__(self, exc_type: None, exc_val: None, exc_tb: None) -> None: 38 | self._patcher.__exit__() 39 | metrics._metrics_env = None # reset caching 40 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/mock_metrics_uuid_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import os 4 | from dataclasses import dataclass 5 | from unittest.mock import patch 6 | 7 | from instance_scheduler.handler.environments.metrics_uuid_environment import ( 8 | MetricsUuidEnvironment, 9 | ) 10 | 11 | 12 | @dataclass 13 | class MockMetricsUuidEnviron(MetricsUuidEnvironment): 14 | user_agent_extra: str = "user-agent-extra" 15 | uuid_key: str = "my-uuid-key" 16 | stack_id: str = "my-stack-id" 17 | 18 | def _to_env_dict(self) -> dict[str, str]: 19 | return { 20 | "USER_AGENT_EXTRA": self.user_agent_extra, 21 | "STACK_ID": self.stack_id, 22 | "UUID_KEY": self.uuid_key, 23 | } 24 | 25 | def __enter__(self) -> "MockMetricsUuidEnviron": 26 | self._patcher = patch.dict(os.environ, self._to_env_dict()) 27 | self._patcher.__enter__() 28 | return self 29 | 30 | def __exit__(self, exc_type: None, exc_val: None, exc_tb: None) -> None: 31 | self._patcher.__exit__() 32 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/mock_orchestrator_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass, field 4 | from zoneinfo import ZoneInfo 5 | 6 | from instance_scheduler.handler.environments.orchestrator_environment import ( 7 | OrchestratorEnvironment, 8 | ) 9 | 10 | 11 | @dataclass(frozen=True) 12 | class MockOrchestratorEnvironment(OrchestratorEnvironment): 13 | # logging 14 | user_agent_extra: str = "my-user-agent-extra" 15 | log_group: str = "my-log-group" 16 | topic_arn: str = "arn:aws:sns:us-east-1:123456789012:my-topic-arn" 17 | enable_debug_logging: bool = True 18 | # references 19 | scheduling_request_handler_name: str = "scheduling-request-handler-lambda" 20 | config_table_name: str = "my-config-table-name" 21 | # scheduling 22 | enable_schedule_hub_account: bool = False 23 | enable_ec2_service: bool = False 24 | enable_rds_service: bool = False 25 | enable_rds_clusters: bool = False 26 | enable_neptune_service: bool = False 27 | enable_docdb_service: bool = False 28 | enable_asg_service: bool = False 29 | schedule_regions: list[str] = field(default_factory=list) 30 | 31 | # used for metrics only 32 | default_timezone: ZoneInfo = ZoneInfo("Asia/Tokyo") 33 | enable_rds_snapshots: bool = True 34 | scheduler_frequency_minutes: int = 5 35 | enable_aws_organizations: bool = False 36 | enable_ec2_ssm_maintenance_windows: bool = False 37 | ops_dashboard_enabled: bool = True 38 | start_tags: list[str] = field( 39 | default_factory=lambda: ["my-first-start-tag", "my-second-start-tag"] 40 | ) 41 | stop_tags: list[str] = field(default_factory=lambda: ["my-stop-tag"]) 42 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/mock_scheduling_request_environment.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from dataclasses import dataclass, field 4 | from zoneinfo import ZoneInfo 5 | 6 | from instance_scheduler.handler.environments.scheduling_request_environment import ( 7 | SchedulingRequestEnvironment, 8 | ) 9 | 10 | 11 | @dataclass(frozen=True) 12 | class MockSchedulingRequestEnvironment(SchedulingRequestEnvironment): 13 | user_agent_extra: str = "my-user-agent-extra" 14 | log_group: str = "my-log-group" 15 | topic_arn: str = "arn:aws:sns:us-east-1:123456789012:my-topic-arn" 16 | enable_debug_logging: bool = False 17 | stack_name: str = "my-stack-name" 18 | state_table_name: str = "my-state-table-name" 19 | config_table_name: str = "my-config-table-name" 20 | maintenance_window_table_name: str = "my-maintenance-window-table" 21 | scheduler_role_name: str = "my-scheduler-role-name" 22 | default_timezone: ZoneInfo = ZoneInfo("Asia/Tokyo") 23 | start_tags: list[str] = field( 24 | default_factory=lambda: ["my-first-start-tag", "my-second-start-tag"] 25 | ) 26 | stop_tags: list[str] = field(default_factory=lambda: ["my-stop-tag"]) 27 | schedule_tag_key: str = "Schedule" 28 | scheduler_frequency_minutes: int = 5 29 | enable_ec2_ssm_maintenance_windows: bool = False 30 | enable_rds_service: bool = True 31 | enable_rds_clusters: bool = True 32 | enable_docdb_service: bool = True 33 | enable_neptune_service: bool = True 34 | enable_rds_snapshots: bool = True 35 | enable_ops_monitoring: bool = True 36 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/testsuite_env.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import os 4 | from dataclasses import dataclass 5 | from unittest.mock import patch 6 | from zoneinfo import ZoneInfo 7 | 8 | 9 | @dataclass 10 | class TestSuiteEnv: 11 | log_group: str = "my-log-group" 12 | topic_arn: str = "arn:aws:sns:us-east-1:123456789012:my-topic-arn" 13 | user_agent_extra: str = "my-user-agent-extra" 14 | default_timezone: ZoneInfo = ZoneInfo("Asia/Tokyo") 15 | maintenance_window_table_name: str = "my-maintenance-window-table" 16 | config_table_name: str = "my-config-table-name" 17 | state_table_name: str = "my-state-table-name" 18 | 19 | def _to_env_dict(self) -> dict[str, str]: 20 | return { 21 | "USER_AGENT_EXTRA": self.user_agent_extra, 22 | "DEFAULT_TIMEZONE": str(self.default_timezone), 23 | } 24 | 25 | def __enter__(self) -> "TestSuiteEnv": 26 | self._patcher = patch.dict(os.environ, self._to_env_dict()) 27 | self._patcher.__enter__() 28 | return self 29 | 30 | def __exit__(self, exc_type: None, exc_val: None, exc_tb: None) -> None: 31 | self._patcher.__exit__() 32 | -------------------------------------------------------------------------------- /source/app/tests/test_utils/unordered_list.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Any, TypeVar 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class UnorderedList(list[T]): 9 | """helper object for asserting unordered equals on lists""" 10 | 11 | def __eq__(self, other: Any) -> bool: 12 | if not isinstance(other, list): 13 | return False 14 | 15 | if len(self) != len(other): 16 | return False 17 | 18 | temp = other.copy() 19 | for item in self: 20 | try: 21 | temp.remove(item) 22 | except ValueError: 23 | return False 24 | 25 | return True 26 | 27 | def __ne__(self, other: Any) -> bool: 28 | return not self.__eq__(other) 29 | 30 | 31 | def test_matches_normally() -> None: 32 | assert UnorderedList([1, 2, 3]) == UnorderedList([3, 2, 1]) 33 | 34 | 35 | def test_not_fooled_by_duplicates() -> None: 36 | assert UnorderedList([1, 2, 2]) != UnorderedList([3, 2, 1]) 37 | -------------------------------------------------------------------------------- /source/app/tests/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/app/tests/util/test_app_env_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import pytest 4 | 5 | from instance_scheduler.util.app_env_utils import env_to_bool, env_to_list 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "truthy_value", 10 | ["True", "true", "true ", "\ttrue\r\n", "\tyes", "Yes", "yes", " yes"], 11 | ) 12 | def test_truthy_env_to_bool(truthy_value: str) -> None: 13 | assert env_to_bool(truthy_value) is True 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "falsy_value", 18 | ["", "False", "false ", "\tfalse\r\n", "No", "no", "\tno", " Anything else"], 19 | ) 20 | def test_falsy_env_to_bool(falsy_value: str) -> None: 21 | assert env_to_bool(falsy_value) is False 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "env_val, expected", 26 | [ 27 | ("", []), 28 | ("a", ["a"]), 29 | ("a,b,c", ["a", "b", "c"]), 30 | ("foo,,bar", ["foo", "bar"]), 31 | ("foo, bar, ", ["foo", "bar"]), 32 | (" , foo , bar, ", ["foo", "bar"]), 33 | ], 34 | ) 35 | def test_to_list(env_val: str, expected: list[str]) -> None: 36 | assert env_to_list(env_val) == expected 37 | -------------------------------------------------------------------------------- /source/app/tests/util/test_display_helper.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import string 4 | 5 | from instance_scheduler.util.display_helper import set_str 6 | 7 | characters = string.ascii_lowercase 8 | names = [c * 3 for c in characters] 9 | 10 | 11 | def test_set_str() -> None: 12 | sep_item = "," 13 | sep_range = "-" 14 | # single item 15 | assert set_str({0}, names) == names[0] 16 | # two items 17 | assert set_str({0, 3}, names) == names[0] + sep_item + names[3] 18 | # range 19 | assert set_str({0, 1, 2, 3, 4}, names) == names[0] + sep_range + names[4] 20 | # range and item 21 | assert ( 22 | set_str({0, 1, 2, 4}, names) 23 | == names[0] + sep_range + names[2] + sep_item + names[4] 24 | ) 25 | # two ranges 26 | assert ( 27 | set_str({0, 1, 3, 4}, names) 28 | == names[0] + sep_range + names[1] + sep_item + names[3] + sep_range + names[4] 29 | ) 30 | -------------------------------------------------------------------------------- /source/app/tests/util/test_init.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from datetime import datetime 4 | from unittest.mock import MagicMock, patch 5 | 6 | from instance_scheduler.handler.environments.main_lambda_environment import ( 7 | MainLambdaEnv, 8 | ) 9 | from instance_scheduler.util import get_boto_config, safe_json 10 | 11 | 12 | def test_safe_json() -> None: 13 | safe_json(datetime.now()) 14 | 15 | 16 | @patch("instance_scheduler.util._Config") 17 | def test_get_config(mock_config: MagicMock, test_suite_env: MainLambdaEnv) -> None: 18 | get_boto_config() 19 | mock_config.assert_called_once_with( 20 | user_agent_extra=test_suite_env.user_agent_extra, 21 | retries={"max_attempts": 5, "mode": "standard"}, 22 | ) 23 | -------------------------------------------------------------------------------- /source/app/tests/util/test_session_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from unittest.mock import ANY, MagicMock, patch 4 | 5 | from instance_scheduler.util.session_manager import assume_role 6 | 7 | 8 | @patch("instance_scheduler.util.session_manager.Session") 9 | def test_uses_regional_sts_endpoint( 10 | mock_session: MagicMock, 11 | ) -> None: 12 | # When assuming a role with sts, if the spoke account does not have the same region enabled as the calling region 13 | # in sts, the assume will fail. To get around this, the IG requires that customers install the hub and spoke stacks 14 | # in the same region (ensuring that the region is enabled in both accounts), as such all sts calls should use this 15 | # local region to ensure proper cross-account, cross-region behavior (at time of writing, the local endpoints return 16 | # a V2 token which is valid in all regions, the global endpoint returns 17 | # a V1 token which is only valid in default regions) 18 | mock_client = MagicMock() 19 | mock_session.return_value.client = mock_client 20 | region_name = "executing-region" 21 | mock_session.return_value.region_name = region_name 22 | 23 | assume_role(account="111122223333", region="us-west-2", role_name="my-role-name") 24 | 25 | mock_client.assert_called_once_with( 26 | "sts", 27 | region_name=region_name, 28 | endpoint_url=f"https://sts.{region_name}.amazonaws.com", 29 | config=ANY, 30 | ) 31 | 32 | 33 | @patch("instance_scheduler.util.session_manager.Session") 34 | def test_uses_correct_domain_in_china( 35 | mock_session: MagicMock, 36 | ) -> None: 37 | region_name = "cn-north-1" 38 | 39 | mock_client = MagicMock() 40 | mock_session.return_value.client = mock_client 41 | mock_session.return_value.region_name = region_name 42 | mock_session.return_value.get_partition_for_region.return_value = "aws-cn" 43 | 44 | assume_role( 45 | account="111122223333", region="cn-northwest-2", role_name="my-role-name" 46 | ) 47 | 48 | mock_client.assert_called_once_with( 49 | "sts", 50 | region_name=region_name, 51 | endpoint_url=f"https://sts.{region_name}.amazonaws.com.cn", 52 | config=ANY, 53 | ) 54 | -------------------------------------------------------------------------------- /source/app/tests/util/test_time.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from datetime import datetime, timezone 4 | from zoneinfo import ZoneInfo 5 | 6 | from instance_scheduler.util.time import is_aware 7 | 8 | 9 | def test_is_aware() -> None: 10 | assert is_aware(datetime(year=2023, month=6, day=23, tzinfo=timezone.utc)) 11 | assert is_aware(datetime(year=2023, month=6, day=23, tzinfo=ZoneInfo("Asia/Tokyo"))) 12 | 13 | assert not is_aware(datetime(year=2023, month=6, day=23)) 14 | -------------------------------------------------------------------------------- /source/app/tox.ini: -------------------------------------------------------------------------------- 1 | ; Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | ; SPDX-License-Identifier: Apache-2.0 3 | [tox] 4 | env_list = format, lint, py311-report, py312-noreport 5 | minversion = 4.0.13 6 | isolated_build = true 7 | 8 | [testenv:format] 9 | skip_install = true 10 | deps = 11 | black~=24.1.0 12 | isort 13 | commands = 14 | isort --profile black --check . 15 | black --check . 16 | 17 | [flake8] 18 | extend-ignore = 19 | # line length, handled by black 20 | E501, 21 | # whitespace, handled by black 22 | E203, 23 | 24 | [testenv:lint] 25 | allowlist_externals = poetry 26 | deps = poetry 27 | commands_pre = poetry install 28 | commands = 29 | poetry run mypy . 30 | poetry run flake8 . 31 | 32 | [testenv:py3{11,12}-{report, noreport}] 33 | allowlist_externals = poetry 34 | deps = poetry 35 | pass_env = PYTHON_VERSION 36 | package = skip 37 | commands_pre = poetry install 38 | commands = 39 | report: poetry run pytest -n auto tests/ {posargs} 40 | noreport: poetry run pytest -n auto tests/ 41 | -------------------------------------------------------------------------------- /source/cli/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = instance_scheduler_cli 4 | 5 | [report] 6 | exclude_lines = 7 | if TYPE_CHECKING: 8 | -------------------------------------------------------------------------------- /source/cli/.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | * text=auto eol=lf 4 | /.gitattributes linguist-generated 5 | /.gitignore linguist-generated 6 | /.projen/** linguist-generated 7 | /.projen/deps.json linguist-generated 8 | /.projen/files.json linguist-generated 9 | /.projen/tasks.json linguist-generated 10 | /pyproject.toml linguist-generated -------------------------------------------------------------------------------- /source/cli/.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | node_modules/ 3 | !/.gitattributes 4 | !/.projen/tasks.json 5 | !/.projen/deps.json 6 | !/.projen/files.json 7 | !/pyproject.toml 8 | /poetry.toml 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | *.so 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | *.manifest 32 | *.spec 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | htmlcov/ 36 | .tox/ 37 | .nox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | *.py,cover 45 | .hypothesis/ 46 | .pytest_cache/ 47 | cover/ 48 | *.mo 49 | *.pot 50 | *.log 51 | local_settings.py 52 | db.sqlite3 53 | db.sqlite3-journal 54 | instance/ 55 | .webassets-cache 56 | .scrapy 57 | docs/_build/ 58 | .pybuilder/ 59 | target/ 60 | .ipynb_checkpoints 61 | profile_default/ 62 | ipython_config.py 63 | __pypackages__/ 64 | celerybeat-schedule 65 | celerybeat.pid 66 | *.sage.py 67 | .env 68 | .venv 69 | env/ 70 | venv/ 71 | ENV/ 72 | env.bak/ 73 | venv.bak/ 74 | .spyderproject 75 | .spyproject 76 | .ropeproject 77 | /site 78 | .mypy_cache/ 79 | .dmypy.json 80 | dmypy.json 81 | .pyre/ 82 | .pytype/ 83 | cython_debug/ 84 | -------------------------------------------------------------------------------- /source/cli/.projen/deps.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | { 4 | "name": "black", 5 | "version": "^24.3.0", 6 | "type": "devenv" 7 | }, 8 | { 9 | "name": "boto3-stubs-lite", 10 | "version": "{version = \"^1.34.1\", extras = [\"cloudformation\",\"lambda\"]}", 11 | "type": "devenv" 12 | }, 13 | { 14 | "name": "flake8", 15 | "version": "^6.1.0", 16 | "type": "devenv" 17 | }, 18 | { 19 | "name": "isort", 20 | "version": "^5.12.0", 21 | "type": "devenv" 22 | }, 23 | { 24 | "name": "jsonschema", 25 | "version": "~4.17.3", 26 | "type": "devenv" 27 | }, 28 | { 29 | "name": "moto", 30 | "version": "{version = \"^5.1.4\", extras = [\"cloudformation\",\"lambda\"]}", 31 | "type": "devenv" 32 | }, 33 | { 34 | "name": "mypy", 35 | "version": "^1.7.1", 36 | "type": "devenv" 37 | }, 38 | { 39 | "name": "pytest-cov", 40 | "version": "^4.1.0", 41 | "type": "devenv" 42 | }, 43 | { 44 | "name": "pytest", 45 | "version": "^7.4.3", 46 | "type": "devenv" 47 | }, 48 | { 49 | "name": "tox", 50 | "version": "^4.11.4", 51 | "type": "devenv" 52 | }, 53 | { 54 | "name": "types-jmespath", 55 | "version": "^1.0.1", 56 | "type": "devenv" 57 | }, 58 | { 59 | "name": "types-PyYAML", 60 | "version": "^6.0.12.12", 61 | "type": "devenv" 62 | }, 63 | { 64 | "name": "types-requests", 65 | "version": "2.31.0.6", 66 | "type": "devenv" 67 | }, 68 | { 69 | "name": "boto3", 70 | "version": "^1.34.1", 71 | "type": "runtime" 72 | }, 73 | { 74 | "name": "jmespath", 75 | "version": "^1.0.1", 76 | "type": "runtime" 77 | }, 78 | { 79 | "name": "python", 80 | "version": "^3.9.0", 81 | "type": "runtime" 82 | } 83 | ], 84 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 85 | } 86 | -------------------------------------------------------------------------------- /source/cli/.projen/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | ".gitattributes", 4 | ".gitignore", 5 | ".projen/deps.json", 6 | ".projen/files.json", 7 | ".projen/tasks.json", 8 | "poetry.toml", 9 | "pyproject.toml" 10 | ], 11 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 12 | } 13 | -------------------------------------------------------------------------------- /source/cli/.projen/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "build": { 4 | "name": "build", 5 | "description": "Full release build", 6 | "steps": [ 7 | { 8 | "spawn": "pre-compile" 9 | }, 10 | { 11 | "spawn": "compile" 12 | }, 13 | { 14 | "spawn": "post-compile" 15 | }, 16 | { 17 | "spawn": "test" 18 | }, 19 | { 20 | "spawn": "package" 21 | } 22 | ] 23 | }, 24 | "compile": { 25 | "name": "compile", 26 | "description": "Only compile" 27 | }, 28 | "default": { 29 | "name": "default", 30 | "description": "Synthesize project files", 31 | "steps": [ 32 | { 33 | "exec": "npx projen default", 34 | "cwd": "../.." 35 | } 36 | ] 37 | }, 38 | "install": { 39 | "name": "install", 40 | "description": "Install dependencies and update lockfile", 41 | "steps": [ 42 | { 43 | "exec": "poetry lock --no-update && poetry install" 44 | } 45 | ] 46 | }, 47 | "install:ci": { 48 | "name": "install:ci", 49 | "description": "Install dependencies with frozen lockfile", 50 | "steps": [ 51 | { 52 | "exec": "poetry check --lock && poetry install" 53 | } 54 | ] 55 | }, 56 | "package": { 57 | "name": "package", 58 | "description": "Creates the distribution package", 59 | "steps": [ 60 | { 61 | "exec": "poetry build" 62 | } 63 | ] 64 | }, 65 | "post-compile": { 66 | "name": "post-compile", 67 | "description": "Runs after successful compilation" 68 | }, 69 | "pre-compile": { 70 | "name": "pre-compile", 71 | "description": "Prepare the project for compilation" 72 | }, 73 | "publish": { 74 | "name": "publish", 75 | "description": "Uploads the package to PyPI.", 76 | "steps": [ 77 | { 78 | "exec": "poetry publish" 79 | } 80 | ] 81 | }, 82 | "publish:test": { 83 | "name": "publish:test", 84 | "description": "Uploads the package against a test PyPI endpoint.", 85 | "steps": [ 86 | { 87 | "exec": "poetry publish -r testpypi" 88 | } 89 | ] 90 | }, 91 | "test": { 92 | "name": "test", 93 | "description": "Run tests" 94 | } 95 | }, 96 | "env": { 97 | "VIRTUAL_ENV": "$(poetry env info -p || poetry run poetry env info -p)", 98 | "PATH": "$(echo $(poetry env info -p)/bin:$PATH)" 99 | }, 100 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 101 | } 102 | -------------------------------------------------------------------------------- /source/cli/README.md: -------------------------------------------------------------------------------- 1 | # Instance Scheduler on AWS CLI 2 | -------------------------------------------------------------------------------- /source/cli/instance_scheduler_cli/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from importlib.metadata import version 4 | 5 | __version__ = version(__package__) 6 | -------------------------------------------------------------------------------- /source/cli/instance_scheduler_cli/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import sys 4 | 5 | from instance_scheduler_cli.scheduler_cli import build_parser 6 | 7 | 8 | def main() -> int: 9 | parser = build_parser() 10 | if len(sys.argv) == 1: 11 | parser.print_help() 12 | return 0 13 | else: 14 | p = parser.parse_args(sys.argv[1:]) 15 | return int(p.func(p, p.command)) 16 | 17 | 18 | sys.exit(main()) 19 | -------------------------------------------------------------------------------- /source/cli/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | 4 | [mypy-moto] 5 | ignore_missing_imports = True 6 | -------------------------------------------------------------------------------- /source/cli/pyproject.toml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | [tool.poetry] 4 | name = "instance_scheduler_cli" 5 | version = "3.0.10" 6 | description = "Instance Scheduler on AWS CLI" 7 | license = "Apache-2.0" 8 | authors = [ "Amazon Web Services" ] 9 | homepage = "https://aws.amazon.com/solutions/implementations/instance-scheduler-on-aws/" 10 | readme = "README.md" 11 | 12 | [tool.poetry.dependencies] 13 | boto3 = "^1.34.1" 14 | jmespath = "^1.0.1" 15 | python = "^3.9.0" 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | black = "^24.3.0" 19 | flake8 = "^6.1.0" 20 | isort = "^5.12.0" 21 | jsonschema = "~4.17.3" 22 | mypy = "^1.7.1" 23 | pytest-cov = "^4.1.0" 24 | pytest = "^7.4.3" 25 | tox = "^4.11.4" 26 | types-jmespath = "^1.0.1" 27 | types-PyYAML = "^6.0.12.12" 28 | types-requests = "2.31.0.6" 29 | 30 | [tool.poetry.group.dev.dependencies.boto3-stubs-lite] 31 | version = "^1.34.1" 32 | extras = [ "cloudformation", "lambda" ] 33 | 34 | [tool.poetry.group.dev.dependencies.moto] 35 | version = "^5.1.4" 36 | extras = [ "cloudformation", "lambda" ] 37 | 38 | [tool.poetry.scripts] 39 | scheduler-cli = "instance_scheduler_cli:__main__" 40 | 41 | [build-system] 42 | requires = [ "poetry-core" ] 43 | build-backend = "poetry.core.masonry.api" 44 | -------------------------------------------------------------------------------- /source/cli/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from os import environ 4 | 5 | from pytest import fixture 6 | 7 | 8 | @fixture(autouse=True) 9 | def aws_credentials() -> None: 10 | environ["AWS_ACCESS_KEY_ID"] = "testing" 11 | environ["AWS_SECRET_ACCESS_KEY"] = "testing" 12 | environ["AWS_SECURITY_TOKEN"] = "testing" 13 | environ["AWS_SESSION_TOKEN"] = "testing" 14 | environ["AWS_DEFAULT_REGION"] = "us-east-1" 15 | -------------------------------------------------------------------------------- /source/cli/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import subprocess 4 | 5 | 6 | def test_run_as_module() -> None: 7 | result = subprocess.run( 8 | ["python", "-m", "instance_scheduler_cli", "--version"], 9 | stdout=subprocess.DEVNULL, 10 | ) 11 | assert result.stderr is None 12 | 13 | 14 | def test_calling_with_no_args_exits_gracefully() -> None: 15 | result = subprocess.run( 16 | [ 17 | "python", 18 | "-m", 19 | "instance_scheduler_cli", 20 | ], 21 | stdout=subprocess.DEVNULL, 22 | ) 23 | assert result.stderr is None 24 | -------------------------------------------------------------------------------- /source/cli/tests/test_enforce_headers.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from os import scandir 4 | from pathlib import Path 5 | from typing import Iterator 6 | 7 | optional_shebang = "#!/usr/bin/env python" 8 | 9 | header_lines = [ 10 | "# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.", 11 | "# SPDX-License-Identifier: Apache-2.0", 12 | ] 13 | 14 | 15 | def valid_header(path: Path) -> bool: 16 | header_length = len(header_lines) 17 | lines = [] 18 | with open(path) as f: 19 | for _ in range(header_length + 1): 20 | lines.append(f.readline()) 21 | line_index = 0 22 | if lines[0].strip() == optional_shebang: 23 | line_index = 1 24 | for header_line in header_lines: 25 | if lines[line_index].strip() != header_line: 26 | return False 27 | line_index += 1 28 | return True 29 | 30 | 31 | exclude_dirs = {".tox", ".mypy_cache"} 32 | 33 | 34 | def python_source_files(path: str) -> Iterator[Path]: 35 | for file in Path(path).glob("*.py"): 36 | if file.stat().st_size > 0: 37 | yield file 38 | for entry in scandir(path): 39 | if entry.is_dir() and entry.name not in exclude_dirs: 40 | yield from python_source_files(entry.path) 41 | 42 | 43 | def test_headers_exist() -> None: 44 | for file_path in python_source_files("."): 45 | assert valid_header( 46 | file_path 47 | ), f"{file_path} does not contain a valid copyright header" 48 | -------------------------------------------------------------------------------- /source/cli/tests/test_service_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import TYPE_CHECKING 4 | 5 | from moto import mock_aws 6 | 7 | from instance_scheduler_cli.scheduler_cli import _service_client 8 | 9 | if TYPE_CHECKING: 10 | from mypy_boto3_cloudformation import CloudFormationClient 11 | else: 12 | CloudFormationClient = object 13 | 14 | 15 | def test_service_client() -> None: 16 | with mock_aws(): 17 | client: CloudFormationClient = _service_client("cloudformation") 18 | assert client.describe_stacks()["Stacks"] == [] 19 | -------------------------------------------------------------------------------- /source/cli/tests/test_version.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from importlib.metadata import version 4 | 5 | from instance_scheduler_cli import __version__ 6 | 7 | 8 | def test_version_correctly_picked_up_from_toml() -> None: 9 | assert __version__ == version("instance_scheduler_cli") 10 | -------------------------------------------------------------------------------- /source/cli/tox.ini: -------------------------------------------------------------------------------- 1 | ; Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | ; SPDX-License-Identifier: Apache-2.0 3 | [tox] 4 | env_list = format, lint, py311-report, py3{9,10,12}-noreport 5 | minversion = 4.0.13 6 | isolated_build = true 7 | 8 | [testenv:format] 9 | skip_install = true 10 | deps = 11 | black~=24.1.0 12 | isort 13 | commands = 14 | isort --profile black --check . 15 | black --check . 16 | 17 | [flake8] 18 | extend-ignore = 19 | # line length, handled by black 20 | E501, 21 | # whitespace, handled by black 22 | E203 23 | 24 | [testenv:lint] 25 | allowlist_externals = poetry 26 | deps = poetry 27 | commands_pre = poetry install 28 | commands = 29 | poetry run mypy . 30 | poetry run flake8 . 31 | 32 | [testenv:py3{8,9,10,11,12}-{report, noreport}] 33 | allowlist_externals = poetry 34 | deps = poetry 35 | pass_env = PYTHON_VERSION 36 | package = skip 37 | commands_pre = poetry install 38 | commands = 39 | report: poetry run pytest tests/ {posargs} 40 | noreport: poetry run pytest tests/ 41 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/anonymized-metrics-environment.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | export interface AnonymizedMetricsEnvironment { 4 | // environment variables for the metrics.py singleton service 5 | // omitting these variables will disable metrics reporting 6 | SEND_METRICS: string; 7 | METRICS_URL: string; 8 | SOLUTION_ID: string; 9 | SOLUTION_VERSION: string; 10 | SCHEDULING_INTERVAL_MINUTES: string; 11 | METRICS_UUID: string; 12 | } 13 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/app-registry.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import * as cdk from "aws-cdk-lib"; 4 | import * as servicecatalogappregistry from "aws-cdk-lib/aws-servicecatalogappregistry"; 5 | import { Construct } from "constructs"; 6 | import { Aws, Stack, Tags } from "aws-cdk-lib"; 7 | 8 | export interface AppRegistryIntegrationProps extends cdk.StackProps { 9 | readonly solutionId: string; 10 | readonly solutionName: string; 11 | readonly solutionVersion: string; 12 | readonly appregAppName: string; 13 | readonly appregSolutionName: string; 14 | } 15 | 16 | export class AppRegistryIntegration extends Construct { 17 | readonly application: servicecatalogappregistry.CfnApplication; 18 | 19 | constructor(scope: Stack, id: string, props: AppRegistryIntegrationProps) { 20 | super(scope, id); 21 | 22 | const map = new cdk.CfnMapping(this, "Solution"); 23 | map.setValue("Data", "ID", props.solutionId); 24 | map.setValue("Data", "Version", props.solutionVersion); 25 | map.setValue("Data", "AppRegistryApplicationName", props.appregSolutionName); 26 | map.setValue("Data", "SolutionName", props.solutionName); 27 | map.setValue("Data", "ApplicationType", props.appregAppName); 28 | 29 | this.application = new servicecatalogappregistry.CfnApplication(scope, "AppRegistry", { 30 | name: cdk.Fn.join("-", [ 31 | map.findInMap("Data", "AppRegistryApplicationName"), 32 | Aws.REGION, 33 | Aws.ACCOUNT_ID, 34 | Aws.STACK_NAME, 35 | ]), 36 | description: `Service Catalog application to track and manage all your resources for the solution ${map.findInMap( 37 | "Data", 38 | "SolutionName", 39 | )}`, 40 | }); 41 | 42 | //update-path backwards compatibility with 3.0.8 43 | this.application.overrideLogicalId("AppRegistry968496A3"); 44 | 45 | Tags.of(this.application).add("Solutions:SolutionID", map.findInMap("Data", "ID")); 46 | Tags.of(this.application).add("Solutions:SolutionName", map.findInMap("Data", "SolutionName")); 47 | Tags.of(this.application).add("Solutions:SolutionVersion", map.findInMap("Data", "Version")); 48 | Tags.of(this.application).add("Solutions:ApplicationType", map.findInMap("Data", "ApplicationType")); 49 | } 50 | 51 | addApplicationTags(resource: Construct) { 52 | Tags.of(resource).add("awsApplication", `${this.application.attrApplicationTagValue}`, { 53 | excludeResourceTypes: ["AWS::ServiceCatalogAppRegistry::Application", "aws:cdk:stack"], 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/cdk-context.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { App } from "aws-cdk-lib"; 4 | 5 | export function getSolutionContext(app: App) { 6 | return { 7 | solutionId: app.node.tryGetContext("solutionId"), 8 | solutionName: app.node.tryGetContext("solutionName"), 9 | appRegAppName: app.node.tryGetContext("appRegApplicationName"), 10 | appRegSolutionName: app.node.tryGetContext("appRegSolutionName"), 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/cfn-nag.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { CfnResource } from "aws-cdk-lib"; 4 | import { IConstruct } from "constructs"; 5 | 6 | export interface CfnNagSuppression { 7 | readonly id: string; 8 | readonly reason: string; 9 | } 10 | 11 | export function addCfnNagSuppressions(resource: IConstruct, ...suppressions: CfnNagSuppression[]): void { 12 | const cfnResource = resource.node.defaultChild as CfnResource; 13 | if (!cfnResource?.cfnOptions) { 14 | throw new Error(`Resource ${cfnResource?.logicalId} has no cfnOptions, unable to add cfn-nag suppression`); 15 | } 16 | const existingSuppressions: CfnNagSuppression[] = cfnResource.cfnOptions.metadata?.cfn_nag?.rules_to_suppress; 17 | if (existingSuppressions) { 18 | existingSuppressions.push(...suppressions); 19 | } else { 20 | cfnResource.cfnOptions.metadata = { 21 | cfn_nag: { 22 | rules_to_suppress: [...suppressions], 23 | }, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/iam/asg-scheduling-permissions-policy.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { Aws } from "aws-cdk-lib"; 4 | import { Policy, PolicyStatement } from "aws-cdk-lib/aws-iam"; 5 | import { NagSuppressions } from "cdk-nag"; 6 | import { addCfnNagSuppressions } from "../cfn-nag"; 7 | import { Construct } from "constructs"; 8 | 9 | export class AsgSchedulingPermissionsPolicy extends Policy { 10 | constructor(scope: Construct, id: string) { 11 | super(scope, id); 12 | 13 | this.addStatements( 14 | new PolicyStatement({ 15 | actions: [ 16 | "autoscaling:BatchPutScheduledUpdateGroupAction", 17 | "autoscaling:BatchDeleteScheduledAction", 18 | "autoscaling:CreateOrUpdateTags", 19 | ], 20 | resources: [`arn:${Aws.PARTITION}:autoscaling:*:${Aws.ACCOUNT_ID}:autoScalingGroup:*:autoScalingGroupName/*`], 21 | }), 22 | new PolicyStatement({ 23 | actions: ["autoscaling:DescribeAutoScalingGroups", "autoscaling:DescribeScheduledActions"], 24 | resources: ["*"], 25 | }), 26 | ); 27 | 28 | addCfnNagSuppressions(this, { 29 | id: "W12", 30 | reason: "DescribeAutoScalingGroups and autoscaling:DescribeScheduledActions actions require wildcard permissions", 31 | }); 32 | 33 | NagSuppressions.addResourceSuppressions(this, [ 34 | { 35 | id: "AwsSolutions-IAM5", 36 | appliesTo: ["Resource::*"], 37 | reason: "Required permissions to describe AutoScaling Groups", 38 | }, 39 | { 40 | id: "AwsSolutions-IAM5", 41 | appliesTo: [ 42 | "Resource::arn::autoscaling:*::autoScalingGroup:*:autoScalingGroupName/*", 43 | ], 44 | reason: "Required permissions to modify scheduled scaling actions on AutoScaling Groups", 45 | }, 46 | ]); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/iam/asg-scheduling-role.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { IPrincipal, Role } from "aws-cdk-lib/aws-iam"; 4 | import { Construct } from "constructs"; 5 | import { addCfnNagSuppressions } from "../cfn-nag"; 6 | import { AsgSchedulingPermissionsPolicy } from "./asg-scheduling-permissions-policy"; 7 | 8 | export interface AsgSchedulingRoleProps { 9 | assumedBy: IPrincipal; 10 | namespace: string; 11 | } 12 | export class AsgSchedulingRole extends Role { 13 | static roleName(namespace: string) { 14 | return `${namespace}-ASG-Scheduling-Role`; 15 | } 16 | constructor(scope: Construct, id: string, props: AsgSchedulingRoleProps) { 17 | super(scope, id, { 18 | assumedBy: props.assumedBy, 19 | roleName: AsgSchedulingRole.roleName(props.namespace), 20 | }); 21 | 22 | new AsgSchedulingPermissionsPolicy(this, `ASGSchedulingPermissions`).attachToRole(this); 23 | 24 | addCfnNagSuppressions(this, { 25 | id: "W28", 26 | reason: "The role name is defined to allow cross account access from the hub account.", 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/iam/ec2-kms-permissions-policy.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { Effect, Policy, PolicyStatement } from "aws-cdk-lib/aws-iam"; 4 | import { Construct } from "constructs"; 5 | import { NagSuppressions } from "cdk-nag"; 6 | export class Ec2KmsPermissionsPolicy extends Policy { 7 | constructor(scope: Construct, id: string, kmsKeyArns: string[]) { 8 | super(scope, id); 9 | 10 | this.addStatements( 11 | new PolicyStatement({ 12 | actions: ["kms:CreateGrant"], 13 | resources: kmsKeyArns, 14 | effect: Effect.ALLOW, 15 | conditions: { 16 | Bool: { 17 | "kms:GrantIsForAWSResource": true, 18 | }, 19 | StringLike: { 20 | "kms:ViaService": "ec2.*.amazonaws.com", 21 | }, 22 | "ForAllValues:StringEquals": { 23 | "kms:GrantOperations": ["Decrypt"], 24 | "kms:EncryptionContextKeys": ["aws:ebs:id"], 25 | }, 26 | Null: { 27 | "kms:EncryptionContextKeys": false, 28 | "kms:GrantOperations": false, 29 | }, 30 | }, 31 | }), 32 | ); 33 | 34 | NagSuppressions.addResourceSuppressions(this, [ 35 | { 36 | id: "AwsSolutions-IAM5", 37 | appliesTo: ["Resource::*"], 38 | reason: 39 | "Specific kms keys are unknown until runtime, for security, access is instead restricted to only granting decryption" + 40 | " permissions to the ec2 service for encrypted EBS volumes", 41 | }, 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/iam/roles.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { Aws } from "aws-cdk-lib"; 5 | 6 | export function roleArnFor(accountId: string, roleName: string) { 7 | return `arn:${Aws.PARTITION}:iam::${accountId}:role/${roleName}`; 8 | } 9 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/iam/scheduler-role.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { IPrincipal, Role } from "aws-cdk-lib/aws-iam"; 4 | import { Construct } from "constructs"; 5 | import { Aspects, CfnCondition, Fn } from "aws-cdk-lib"; 6 | import { ConditionAspect } from "../cfn"; 7 | import { Ec2KmsPermissionsPolicy } from "./ec2-kms-permissions-policy"; 8 | import { SchedulingPermissionsPolicy } from "./scheduling-permissions-policy"; 9 | import { addCfnNagSuppressions } from "../cfn-nag"; 10 | 11 | export interface ScheduleRoleProps { 12 | assumedBy: IPrincipal; 13 | namespace: string; 14 | kmsKeys: string[]; 15 | } 16 | export class SchedulerRole extends Role { 17 | static roleName(namespace: string) { 18 | return `${namespace}-Scheduler-Role`; 19 | } 20 | constructor(scope: Construct, id: string, props: ScheduleRoleProps) { 21 | super(scope, id, { 22 | assumedBy: props.assumedBy, 23 | roleName: SchedulerRole.roleName(props.namespace), 24 | }); 25 | 26 | new SchedulingPermissionsPolicy(this, `SchedulingPermissions`).attachToRole(this); 27 | 28 | //optional KMS permissions 29 | const kmsCondition = new CfnCondition(this, "kmsAccessCondition", { 30 | expression: Fn.conditionNot(Fn.conditionEquals(Fn.select(0, props.kmsKeys), "")), 31 | }); 32 | const kmsConditionAspect = new ConditionAspect(kmsCondition); 33 | const kmsAccess = new Ec2KmsPermissionsPolicy(this, `KmsPermissions`, props.kmsKeys); 34 | kmsAccess.attachToRole(this); 35 | Aspects.of(kmsAccess).add(kmsConditionAspect); 36 | 37 | addCfnNagSuppressions(this, { 38 | id: "W28", 39 | reason: "The role name is defined to allow cross account access from the hub account.", 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/lambda-functions/function-factory.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { PythonFunction } from "@aws-cdk/aws-lambda-python-alpha"; 4 | import { Duration } from "aws-cdk-lib"; 5 | import { IRole } from "aws-cdk-lib/aws-iam"; 6 | import { Code, Function as LambdaFunction, Runtime, Tracing } from "aws-cdk-lib/aws-lambda"; 7 | import { ILogGroup } from "aws-cdk-lib/aws-logs"; 8 | import { Construct } from "constructs"; 9 | import path from "path"; 10 | 11 | export interface FunctionProps { 12 | readonly functionName?: string; 13 | readonly description: string; 14 | readonly index: string; 15 | readonly handler: string; 16 | readonly role: IRole; 17 | readonly memorySize: number; 18 | readonly timeout: Duration; 19 | readonly logGroup?: ILogGroup; 20 | environment: { [_: string]: string }; 21 | } 22 | 23 | export abstract class FunctionFactory { 24 | abstract createFunction(scope: Construct, id: string, props: FunctionProps): LambdaFunction; 25 | } 26 | 27 | export class PythonFunctionFactory extends FunctionFactory { 28 | override createFunction(scope: Construct, id: string, props: FunctionProps): LambdaFunction { 29 | return new PythonFunction(scope, id, { 30 | functionName: props.functionName, 31 | description: props.description, 32 | entry: path.join(__dirname, "..", "..", "..", "app"), 33 | index: props.index, 34 | handler: props.handler, 35 | runtime: Runtime.PYTHON_3_11, 36 | role: props.role, 37 | memorySize: props.memorySize, 38 | timeout: props.timeout, 39 | logGroup: props.logGroup, 40 | environment: props.environment, 41 | tracing: Tracing.ACTIVE, 42 | bundling: { assetExcludes: [".mypy_cache", ".tox", "__pycache__", "tests"] }, 43 | }); 44 | } 45 | } 46 | 47 | export class TestFunctionFactory extends FunctionFactory { 48 | override createFunction(scope: Construct, id: string, props: FunctionProps): LambdaFunction { 49 | return new LambdaFunction(scope, id, { 50 | code: Code.fromAsset(path.join(__dirname, "..", "..", "tests", "test_function")), 51 | runtime: Runtime.PYTHON_3_11, 52 | functionName: props.functionName, 53 | description: props.description, 54 | handler: props.handler, 55 | role: props.role, 56 | memorySize: props.memorySize, 57 | timeout: props.timeout, 58 | logGroup: props.logGroup, 59 | environment: props.environment, 60 | tracing: Tracing.ACTIVE, 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/runbooks/spoke-deregistration.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { 4 | AutomationDocument, 5 | DocumentFormat, 6 | Input, 7 | HardCodedString, 8 | InvokeLambdaFunctionStep, 9 | StringVariable, 10 | HardCodedStringMap, 11 | } from "@cdklabs/cdk-ssm-documents"; 12 | import { Stack } from "aws-cdk-lib"; 13 | import { Role, ServicePrincipal } from "aws-cdk-lib/aws-iam"; 14 | import { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda"; 15 | import { InvocationType } from "aws-cdk-lib/triggers"; 16 | import { NagSuppressions } from "cdk-nag"; 17 | 18 | export interface SpokeDeregistrationRunbookProperties { 19 | lambdaFunction: LambdaFunction; 20 | namespace: string; 21 | } 22 | 23 | export class SpokeDeregistrationRunbook { 24 | constructor(scope: Stack, props: SpokeDeregistrationRunbookProperties) { 25 | const role = new Role(scope, "SpokeDeregistrationRunbookRole", { 26 | assumedBy: new ServicePrincipal("ssm.amazonaws.com"), 27 | description: "Role assumed by SSM Automation to call the spoke registration lambda", 28 | }); 29 | props.lambdaFunction.grantInvoke(role); 30 | 31 | const automationDocument = new AutomationDocument(scope, "SpokeDeregistrationRunbook", { 32 | description: "Deregister a spoke account from Instance Scheduler on AWS on demand", 33 | documentFormat: DocumentFormat.YAML, 34 | assumeRole: HardCodedString.of(role.roleArn), 35 | docInputs: [ 36 | Input.ofTypeString("AccountId", { 37 | description: "Spoke Account ID used for registration", 38 | allowedPattern: "^\\d{12}$", 39 | }), 40 | ], 41 | }); 42 | 43 | automationDocument.addStep( 44 | new InvokeLambdaFunctionStep(scope, "InvokeSpokeRegistrationLambdaStep", { 45 | name: "InvokeSpokeRegistrationLambda", 46 | description: 47 | "Invokes the Instance Scheduler on AWS spoke registration lambda to deregister a given AWS Account ID", 48 | functionName: HardCodedString.of(props.lambdaFunction.functionArn), 49 | invocationType: HardCodedString.of(InvocationType.REQUEST_RESPONSE), 50 | payload: HardCodedStringMap.of({ 51 | account: StringVariable.of("AccountId"), 52 | operation: "Deregister", 53 | }), 54 | }), 55 | ); 56 | 57 | const defaultPolicy = role.node.tryFindChild("DefaultPolicy"); 58 | if (!defaultPolicy) { 59 | throw Error("Unable to find default policy on role"); 60 | } 61 | 62 | NagSuppressions.addResourceSuppressions(defaultPolicy, [ 63 | { 64 | id: "AwsSolutions-IAM5", 65 | appliesTo: ["Resource:::*"], 66 | reason: "permissions to invoke all versions of the spoke registration lambda", 67 | }, 68 | ]); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /source/instance-scheduler/lib/scheduling-interval-mappings.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { CfnMapping, CfnMappingProps } from "aws-cdk-lib"; 4 | import { Construct } from "constructs"; 5 | 6 | export const schedulerIntervalValues = ["1", "2", "5", "10", "15", "30", "60"]; 7 | export class SchedulingIntervalToCron extends CfnMapping { 8 | private readonly key = "IntervalMinutesToCron"; 9 | constructor(scope: Construct, id: string, props: CfnMappingProps) { 10 | super(scope, id, props); 11 | this.setValue(this.key, "1", "cron(0/1 * * * ? *)"); 12 | this.setValue(this.key, "2", "cron(0/2 * * * ? *)"); 13 | this.setValue(this.key, "5", "cron(0/5 * * * ? *)"); 14 | this.setValue(this.key, "10", "cron(0/10 * * * ? *)"); 15 | this.setValue(this.key, "15", "cron(0/15 * * * ? *)"); 16 | this.setValue(this.key, "30", "cron(0/30 * * * ? *)"); 17 | this.setValue(this.key, "60", "cron(0 0/1 * * ? *)"); 18 | } 19 | 20 | getMapping(schedulingInterval: string) { 21 | return this.findInMap(this.key, schedulingInterval); 22 | } 23 | } 24 | 25 | export class SchedulingIntervalToSeconds extends CfnMapping { 26 | private readonly key = "MinutesToSeconds"; 27 | constructor(scope: Construct, id: string, props: CfnMappingProps) { 28 | super(scope, id, props); 29 | this.setValue(this.key, "1", "60"); 30 | this.setValue(this.key, "2", "120"); 31 | this.setValue(this.key, "5", "300"); 32 | this.setValue(this.key, "10", "600"); 33 | this.setValue(this.key, "15", "900"); 34 | this.setValue(this.key, "30", "1800"); 35 | this.setValue(this.key, "60", "3600"); 36 | } 37 | 38 | getMapping(schedulingInterval: string) { 39 | return this.findInMap(this.key, schedulingInterval); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/init-jest-extended.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import * as matchers from "jest-extended"; 4 | import "jest-extended/all.js"; 5 | 6 | expect.extend(matchers); 7 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/instance-scheduler-remote-stack.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { createSpokeStack } from "./instance-scheduler-stack-factory"; 4 | import { Template } from "aws-cdk-lib/assertions"; 5 | 6 | // share Templates for testing to avoid redundant Docker builds 7 | const remoteStack = Template.fromStack(createSpokeStack({ targetPartition: "Commercial" })); 8 | const remoteStackChina = Template.fromStack(createSpokeStack({ targetPartition: "China" })); 9 | 10 | test("InstanceSchedulerRemoteStack snapshot test", () => { 11 | const resources = remoteStack.findResources("AWS::Lambda::Function"); 12 | const remoteStackJson = remoteStack.toJSON(); 13 | 14 | for (const lambda_function in resources) { 15 | remoteStackJson["Resources"][lambda_function]["Properties"]["Code"] = 16 | "Omitted to remove snapshot dependency on code hash"; 17 | } 18 | expect(remoteStackJson).toMatchSnapshot(); 19 | }); 20 | 21 | test("InstanceSchedulerRemoteChinaStack snapshot test", () => { 22 | const resources = remoteStackChina.findResources("AWS::Lambda::Function"); 23 | const remoteStackJson = remoteStackChina.toJSON(); 24 | 25 | for (const lambda_function in resources) { 26 | remoteStackJson["Resources"][lambda_function]["Properties"]["Code"] = 27 | "Omitted to remove snapshot dependency on code hash"; 28 | } 29 | expect(remoteStackJson).toMatchSnapshot(); 30 | }); 31 | 32 | test("Commercial stack includes AppRegistry", () => { 33 | const appRegResources = remoteStack.findResources("AWS::ServiceCatalogAppRegistry::Application"); 34 | 35 | expect(appRegResources).not.toBeEmpty(); 36 | }); 37 | 38 | test("China stack does not include AppRegistry", () => { 39 | const appRegResources = remoteStackChina.findResources("AWS::ServiceCatalogAppRegistry::Application"); 40 | 41 | expect(appRegResources).toBeEmpty(); 42 | }); 43 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/instance-scheduler-stack-factory.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { App, Aspects, Stack } from "aws-cdk-lib"; 4 | import { AwsSolutionsChecks } from "cdk-nag"; 5 | import { InstanceSchedulerStack } from "../lib/instance-scheduler-stack"; 6 | import { TestFunctionFactory } from "../lib/lambda-functions/function-factory"; 7 | import { SpokeStack } from "../lib/remote-stack"; 8 | 9 | export interface StackCreationProps { 10 | targetPartition: "Commercial" | "China"; 11 | } 12 | 13 | export function createHubStack(props: StackCreationProps): Stack { 14 | const app = new App(); 15 | Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); 16 | return new InstanceSchedulerStack(app, "stack", { 17 | targetPartition: props.targetPartition, 18 | solutionId: "my-solution-id", 19 | solutionName: "my-solution-name", 20 | solutionVersion: "v9.9.9", 21 | appregApplicationName: "my-appreg-app-name", 22 | appregSolutionName: "my-appreg-solution-name", 23 | factory: new TestFunctionFactory(), 24 | }); 25 | } 26 | 27 | export function createSpokeStack(props: StackCreationProps): Stack { 28 | const app = new App(); 29 | Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); 30 | return new SpokeStack(app, "stack", { 31 | targetPartition: props.targetPartition, 32 | solutionId: "my-solution-id", 33 | solutionName: "my-solution-name", 34 | solutionVersion: "v9.9.9", 35 | appregApplicationName: "my-appreg-app-name", 36 | appregSolutionName: "my-appreg-solution-name", 37 | factory: new TestFunctionFactory(), 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/asg-scheduler.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { Template } from "aws-cdk-lib/assertions"; 5 | import { conditions, createAsgSchedulerStack } from "../test_utils/stack-factories"; 6 | 7 | // Brief type of CloudFormation resource for testing 8 | type CfnResourceType = { 9 | readonly Type: string; 10 | readonly Properties: unknown; 11 | readonly Condition?: string; 12 | }; 13 | 14 | it("should put a condition on every resource in AsgScheduler", () => { 15 | const id = "ASGSchedulerTest"; 16 | const asgSchedulerStack = createAsgSchedulerStack(id); 17 | const jsonTemplate = Template.fromStack(asgSchedulerStack).toJSON(); 18 | const resources: { [key: string]: CfnResourceType } = jsonTemplate.Resources; 19 | 20 | if (!resources) throw new Error("Resources not found."); 21 | 22 | for (const key in resources) { 23 | const condition = resources[key].Condition; 24 | 25 | if (key.startsWith(id)) { 26 | expect(condition).toEqual(conditions.enableAsgs); 27 | } else { 28 | expect(condition).not.toEqual(conditions.enableAsgs); 29 | } 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/cfn-nag.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { CfnResource, Stack } from "aws-cdk-lib"; 4 | import { Bucket } from "aws-cdk-lib/aws-s3"; 5 | import { addCfnNagSuppressions, CfnNagSuppression } from "../../lib/cfn-nag"; 6 | 7 | describe("add cfn-nag suppression", function () { 8 | it("adds suppression when none present", function () { 9 | const stack = new Stack(); 10 | const bucket = new Bucket(stack, "Bucket"); 11 | const suppression: CfnNagSuppression = { id: "my id", reason: "my reason" }; 12 | addCfnNagSuppressions(bucket, suppression); 13 | expect((bucket.node.defaultChild as CfnResource).cfnOptions.metadata?.cfn_nag?.rules_to_suppress).toStrictEqual( 14 | expect.arrayContaining([suppression]), 15 | ); 16 | }); 17 | 18 | it("adds suppression when already present", function () { 19 | const stack = new Stack(); 20 | const bucket = new Bucket(stack, "Bucket"); 21 | const firstSuppression: CfnNagSuppression = { id: "my id", reason: "my reason" }; 22 | const secondSuppression: CfnNagSuppression = { id: "another id", reason: "another reason" }; 23 | const thirdSuppression: CfnNagSuppression = { id: "final id", reason: "final reason" }; 24 | (bucket.node.defaultChild as CfnResource).cfnOptions.metadata = { 25 | cfn_nag: { rules_to_suppress: [firstSuppression] }, 26 | }; 27 | addCfnNagSuppressions(bucket, secondSuppression, thirdSuppression); 28 | expect((bucket.node.defaultChild as CfnResource).cfnOptions.metadata?.cfn_nag?.rules_to_suppress).toStrictEqual( 29 | expect.arrayContaining([firstSuppression, secondSuppression, thirdSuppression]), 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/lambda-functions/asg-handler.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { findResource } from "../../test_utils/stack-factories"; 4 | 5 | describe("asg-handler", function () { 6 | const asgPermissionsPolicy = findResource("AWS::IAM::Policy", "ASGPolicy"); 7 | 8 | test("has SNS publish permissions", function () { 9 | expect(asgPermissionsPolicy.Properties.PolicyDocument.Statement).toEqual( 10 | expect.arrayContaining([ 11 | { 12 | Action: expect.arrayContaining(["kms:Decrypt", "kms:GenerateDataKey*"]), 13 | Effect: "Allow", 14 | Resource: { "Fn::GetAtt": ["InstanceSchedulerEncryptionKey", "Arn"] }, 15 | }, 16 | { 17 | Action: "sns:Publish", 18 | Effect: "Allow", 19 | Resource: { 20 | Ref: "InstanceSchedulerSnsTopic", 21 | }, 22 | }, 23 | ]), 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/lambda-functions/scheduling-orchestrator.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import { findResource } from "../../test_utils/stack-factories"; 5 | 6 | describe("scheduling-orchestrator", function () { 7 | const orchestratorPermissionsPolicy = findResource("AWS::IAM::Policy", "SchedulingOrchestratorPermissionsPolicy"); 8 | 9 | test("has SNS publish permissions", function () { 10 | expect(orchestratorPermissionsPolicy.Properties.PolicyDocument.Statement).toEqual( 11 | expect.arrayContaining([ 12 | { 13 | Action: expect.arrayContaining(["kms:Decrypt", "kms:GenerateDataKey*"]), 14 | Effect: "Allow", 15 | Resource: { "Fn::GetAtt": ["InstanceSchedulerEncryptionKey", "Arn"] }, 16 | }, 17 | { 18 | Action: "sns:Publish", 19 | Effect: "Allow", 20 | Resource: { 21 | Ref: "InstanceSchedulerSnsTopic", 22 | }, 23 | }, 24 | ]), 25 | ); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/lambda-functions/scheduling-request-handler.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { findResource } from "../../test_utils/stack-factories"; 4 | 5 | describe("scheduling-request-handler", function () { 6 | const schedulingRequestHandlerPolicy = findResource("AWS::IAM::Policy", "schedulingRequestHandlerPolicy"); 7 | 8 | test("has SNS publish permissions", function () { 9 | expect(schedulingRequestHandlerPolicy.Properties.PolicyDocument.Statement).toEqual( 10 | expect.arrayContaining([ 11 | { 12 | Action: expect.arrayContaining(["kms:Decrypt", "kms:GenerateDataKey*"]), 13 | Effect: "Allow", 14 | Resource: { "Fn::GetAtt": ["InstanceSchedulerEncryptionKey", "Arn"] }, 15 | }, 16 | { 17 | Action: "sns:Publish", 18 | Effect: "Allow", 19 | Resource: { 20 | Ref: "InstanceSchedulerSnsTopic", 21 | }, 22 | }, 23 | ]), 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/lambda-functions/spoke-registration.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { findResource } from "../../test_utils/stack-factories"; 4 | 5 | describe("spoke-registration-handler", function () { 6 | const spokeRegistrationPolicy = findResource("AWS::IAM::Policy", "SpokeRegistrationPolicy"); 7 | 8 | test("has SNS publish permissions", function () { 9 | expect(spokeRegistrationPolicy.Properties.PolicyDocument.Statement).toEqual( 10 | expect.arrayContaining([ 11 | { 12 | Action: expect.arrayContaining(["kms:Decrypt", "kms:GenerateDataKey*"]), 13 | Effect: "Allow", 14 | Resource: { "Fn::GetAtt": ["InstanceSchedulerEncryptionKey", "Arn"] }, 15 | }, 16 | { 17 | Action: "sns:Publish", 18 | Effect: "Allow", 19 | Resource: { 20 | Ref: "InstanceSchedulerSnsTopic", 21 | }, 22 | }, 23 | ]), 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/ops-insights-dashboard.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { conditions, findResource } from "../test_utils/stack-factories"; 4 | 5 | describe("OpsInsights dashboard", function () { 6 | const opsInsightsDashboard = findResource("AWS::CloudWatch::Dashboard", "OperationalInsightsDashboard"); 7 | 8 | test("is conditional on being enabled", function () { 9 | expect(opsInsightsDashboard).toHaveProperty("Condition", conditions.deployOpsInsightsDashboard); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/runbooks/spoke-deregistration.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { findResource } from "../../test_utils/stack-factories"; 4 | 5 | describe("SpokeDeregistrationRunbook", () => { 6 | it("", () => { 7 | const runbookPolicy = findResource("AWS::IAM::Policy", "SpokeDeregistrationRunbookRoleDefaultPolicy"); 8 | expect(runbookPolicy.Properties.PolicyDocument.Statement).toEqual( 9 | expect.arrayContaining([ 10 | { 11 | Action: "lambda:InvokeFunction", 12 | Effect: "Allow", 13 | Resource: [ 14 | { 15 | "Fn::GetAtt": [expect.stringContaining("SpokeRegistrationHandler"), "Arn"], 16 | }, 17 | { 18 | "Fn::Join": [ 19 | "", 20 | [ 21 | { 22 | "Fn::GetAtt": [expect.stringContaining("SpokeRegistrationHandler"), "Arn"], 23 | }, 24 | ":*", 25 | ], 26 | ], 27 | }, 28 | ], 29 | }, 30 | ]), 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/lib/spoke-registration.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import { RemovalPolicy, Stack } from "aws-cdk-lib"; 4 | import { Template } from "aws-cdk-lib/assertions"; 5 | import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb"; 6 | import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs"; 7 | import { Topic } from "aws-cdk-lib/aws-sns"; 8 | import { trueCondition } from "../../lib/cfn"; 9 | import { TestFunctionFactory } from "../../lib/lambda-functions/function-factory"; 10 | import { SpokeRegistrationLambda } from "../../lib/lambda-functions/spoke-registration"; 11 | 12 | function mockConfigTable(scope: Stack) { 13 | return new Table(scope, "ConfigTable", { 14 | sortKey: { name: "name", type: AttributeType.STRING }, 15 | partitionKey: { name: "type", type: AttributeType.STRING }, 16 | billingMode: BillingMode.PAY_PER_REQUEST, 17 | removalPolicy: RemovalPolicy.DESTROY, 18 | pointInTimeRecovery: true, 19 | }); 20 | } 21 | 22 | function mockErrorTopic(scope: Stack) { 23 | return new Topic(scope, "mockedErrorTopic", {}); 24 | } 25 | 26 | function mockLogGroup(scope: Stack) { 27 | return new LogGroup(scope, "mockedLogGroup", {}); 28 | } 29 | describe("spoke-registration", function () { 30 | describe("with aws-organizations enabled", function () { 31 | //setup 32 | const stack = new Stack(); 33 | const configTable = mockConfigTable(stack); 34 | const errorTopic = mockErrorTopic(stack); 35 | const logGroup = mockLogGroup(stack); 36 | new SpokeRegistrationLambda(stack, { 37 | solutionVersion: "v9.9.9", 38 | logRetentionDays: RetentionDays.FIVE_DAYS, 39 | configTable: configTable, 40 | snsErrorReportingTopic: errorTopic, 41 | scheduleLogGroup: logGroup, 42 | USER_AGENT_EXTRA: "user-agent-extra", 43 | enableDebugLogging: trueCondition(stack, "EnableDebugLogging"), 44 | principals: ["o-1234567"], 45 | namespace: "namespace", 46 | enableAwsOrganizations: trueCondition(stack, "EnableAwsOrganizations"), 47 | factory: new TestFunctionFactory(), 48 | }); 49 | 50 | const template = Template.fromStack(stack); 51 | 52 | describe("spoke-registration-lambda", function () { 53 | const lambdaPermissionResources = template.findResources("AWS::Lambda::Permission"); 54 | expect(lambdaPermissionResources).toContainKey("SpokeRegistrationLambdaCrossAccountPermission"); 55 | const lambdaPermission = lambdaPermissionResources["SpokeRegistrationLambdaCrossAccountPermission"]; 56 | it("is conditional on AwsOrganizations", function () { 57 | expect(lambdaPermission["Condition"]).toEqual("EnableAwsOrganizations"); 58 | }); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/test_function/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /source/instance-scheduler/tests/test_function/test_function.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import TYPE_CHECKING, Any 4 | 5 | if TYPE_CHECKING: 6 | from aws_lambda_powertools.utilities.typing import LambdaContext 7 | else: 8 | LambdaContext = object 9 | 10 | 11 | def lambda_handler(_: dict[str, Any], __: LambdaContext) -> None: 12 | """noop""" 13 | 14 | 15 | def handle_metrics_uuid_request(_: dict[str, Any], __: LambdaContext) -> None: 16 | """noop""" 17 | 18 | 19 | def handle_orchestration_request(_: dict[str, Any], __: LambdaContext) -> None: 20 | """noop""" 21 | 22 | 23 | def handle_spoke_registration_event(_: dict[str, Any], __: LambdaContext) -> None: 24 | """noop""" 25 | 26 | 27 | def handle_scheduling_request(_: dict[str, Any], __: LambdaContext) -> None: 28 | """noop""" 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.ts and run "npx projen". 2 | { 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "build/cdk.ts.dist", 6 | "alwaysStrict": true, 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "inlineSourceMap": true, 11 | "inlineSources": true, 12 | "lib": [ 13 | "es2022", 14 | "dom" 15 | ], 16 | "module": "NodeNext", 17 | "noEmitOnError": false, 18 | "noFallthroughCasesInSwitch": false, 19 | "noImplicitAny": true, 20 | "noImplicitReturns": true, 21 | "noImplicitThis": true, 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "resolveJsonModule": true, 25 | "strict": true, 26 | "strictNullChecks": true, 27 | "strictPropertyInitialization": false, 28 | "stripInternal": true, 29 | "target": "ES2022", 30 | "moduleResolution": "nodenext", 31 | "typeRoots": [ 32 | "./node_modules/@types" 33 | ], 34 | "forceConsistentCasingInFileNames": true, 35 | "noPropertyAccessFromIndexSignature": false, 36 | "noUncheckedIndexedAccess": false 37 | }, 38 | "include": [ 39 | "source/**/*.ts", 40 | "deployment/cdk-solution-helper/**/*.ts", 41 | ".projenrc.ts", 42 | "projenrc/**/*.ts" 43 | ], 44 | "exclude": [ 45 | "node_modules", 46 | "cdk.out" 47 | ], 48 | "files": [ 49 | "global.d.ts" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /update-all-dependencies.sh: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | echo "Upgrading projen..." 5 | projen upgrade 6 | 7 | echo "Upgrading root npm..." 8 | npm update 9 | 10 | echo "Upgrading app..." 11 | pushd source/app || exit 12 | poetry update 13 | popd || exit 14 | 15 | echo "Upgrading cli..." 16 | pushd source/cli || exit 17 | poetry update 18 | popd || exit 19 | 20 | echo "Upgrading cdk-solution-helper..." 21 | pushd deployment/cdk-solution-helper || exit 22 | npm update 23 | popd || exit 24 | 25 | echo "All dependencies successfully updated" 26 | echo "If you need to also update the solution and/or CDK version, do so inside .projenrc.ts and then re-run this script" --------------------------------------------------------------------------------