├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── lambda ├── 01_trigger_step_function │ ├── app.py │ ├── requirements.txt │ ├── test_events │ │ └── test_event.py │ ├── test_files │ │ ├── sample_account.yaml │ │ └── sample_account_terminate.yaml │ └── test_trigger.py ├── 02_create_update_delete_stack_instances │ ├── app.py │ ├── requirements.txt │ └── test_create_update_delete.py └── 03_verify_stack_instance_creation │ ├── app.py │ ├── requirements.txt │ └── test_verify.py ├── shared └── fixtures.py ├── stackset-examples └── vpc.yaml └── template.yaml /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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build package changes deploy test help 2 | 3 | build: ## Build the SAM application locally using an AWS Lamnbda-like container 4 | sam build --use-container 5 | 6 | package: ## Package the SAM application and upload it to an s3 bucket along with its dependencies 7 | sam package --s3-prefix stackset-orchestration \ 8 | --s3-bucket $(s3_bucket) \ 9 | --output-template-file template-output.yaml 10 | 11 | changes: build package ## View the SAM application changeset 12 | sam deploy --template-file template-output.yaml \ 13 | --stack-name stackset-orchestration \ 14 | --parameter-overrides \ 15 | StacksetAdministratorPrincipal=$(stackset_administrator_principal) \ 16 | --capabilities CAPABILITY_NAMED_IAM \ 17 | --no-execute-changeset 18 | 19 | deploy: build package ## Deploy the SAM application 20 | sam deploy --template-file template-output.yaml \ 21 | --stack-name stackset-orchestration \ 22 | --parameter-overrides \ 23 | StackSetAdministratorPrincipal=$(stackset_administrator_principal) \ 24 | --capabilities CAPABILITY_NAMED_IAM 25 | 26 | test: ## Run tests 27 | LAMBDA_DIR=$${PWD}/lambda \ 28 | PYTHONPATH=$$PYTHONPATH:$${PWD}/shared \ 29 | AWS_DEFAULT_REGION=eu-west-1 \ 30 | pytest -vvvv 31 | 32 | help: ## Display this help screen 33 | @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS CloudFormation StackSet Orchestration: Automated deployment using AWS Step Functions 2 | 3 | This project allows you to orchestrate AWS CloudFormation StackSet instance creation by using YAML files on an Amazon S3 bucket. 4 | 5 | For further information, see the [blog post related to this repository](https://aws.amazon.com/blogs/mt/aws-cloudformation-stackset-orchestration-automated-deployment-using-aws-step-functions/). 6 | 7 | ## Purposes 8 | 9 | We often use StackSets to automatically deploy infrastructure into many different accounts. Whether they 10 | are Control-Tower-managed or Organizations-managed accounts, StackSets provide a simple and automated 11 | way to handle the creation of resources and infrastructure right after provisioning a new account. 12 | 13 | You can automatically deploy StackSets to accounts which belong to one or many specific Organizational Units 14 | in [AWS Organizations](https://aws.amazcom/about-aws/whats-new/2020/02/aws-cloudformation-stacksets-introduces-automatic-deployments-across-accounts-and-regions-through-aws-organizations/). 15 | Nevertheless, this workflow is not suitable for every use-case, specially when you need to override parameters of 16 | the StackSets depending on the target account. 17 | 18 | To provide a solution to this issue, we have created this project which allows you to automatically deploy StackSet 19 | instances into specific accounts by using S3, AWS Step Functions and YAML configuration files. We used this implementation 20 | because it allowed us to specify the StackSet deployment configuration of our accounts as source code files, which 21 | is a characteristic of the Infrastructure as Code paradigm, and it goes along with the DevOps culture. 22 | 23 | ## Security considerations 24 | 25 | The implementation is safe security-wise due to the automation of all of the deployment and deletion operations. 26 | The remaining risk surface is the input of files to the S3 bucket, which can be protected using standard S3 27 | security mechanisms (such as S3 Bucket policies or IAM policies), or whichever method you see fit. 28 | 29 | This example uses an IAM Role (StacksetAdministrator), created with a Trust Relationship which allows an AWS Principal 30 | specified as a parameter at deployment time to assume it and put objects in the Bucket. 31 | 32 | ## Requirements 33 | 34 | - This implementation uses the AWS Serverless Application Model (SAM) (https://aws.amazon.com/serverless/sam/) in order to deploy the required infrastructure. Be sure to install the SAM CLI if you want to deploy the code. Follow the recommended installation procedure (https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) according to your Operating System. 35 | 36 | - Docker is also needed since it is used for the SAM build (--use-container) to locally compiles the lambda functions in a Docker container that functions like a Lambda environment, so they are in the right format when you deploy them to the AWS Cloud. 37 | 38 | - An S3 bucket is needed, which will be used by the SAM CLI to upload the Lambda packages that will be used to provision the Lambda functions. This is not to be confused with the bucket which will contain the YAML configuration files, which will be created by the CloudFormation template at deployment time. 39 | 40 | - Also, make (https://www.gnu.org/software/make/) is used to deploy the resources, wrapping the SAM CLI commands. If you do not have make on your workstation, or you do not wish to install it, you can run the commands that are specified inside of the Makefile manually. 41 | 42 | - You will need pytest (https://docs.pytest.org/en/6.2.x/) for launching the test suite. 43 | 44 | ## Deployment 45 | 46 | In order to deploy the implementation, follow these steps: 47 | 48 | ``` 49 | # Clone the respository 50 | git clone https://github.com/aws-samples/aws-cloudformation-stackset-orchestration 51 | 52 | # Move to the repository's directory 53 | cd aws-cloudformation-stackset-orchestration 54 | 55 | # Use the deploy target on the provided Makefile, provide your own bucket name 56 | make deploy s3-bucket=yourbucketname stackset_administrator_principal=arn:aws:iam::123456789012:role/Admin 57 | ``` 58 | 59 | Be sure to configure your AWS credentials before running the previous step. This 60 | will package all of the Lambda functions, and upload them to the specified S3 bucket. 61 | Once the deployment is done, you can move ahead and use the implementation. 62 | 63 | ## Contributing 64 | 65 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 66 | 67 | ## License 68 | 69 | This library is licensed under the MIT-0 License. See the LICENSE file. 70 | 71 | -------------------------------------------------------------------------------- /lambda/01_trigger_step_function/app.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | # the Software, and to permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | import json 17 | import os 18 | import urllib.parse 19 | 20 | import boto3 21 | import yaml 22 | 23 | s3 = boto3.client("s3") 24 | step_functions = boto3.client("stepfunctions") 25 | 26 | STATE_MACHINE_ARN = os.getenv("STATE_MACHINE") 27 | 28 | 29 | def get_config_file(event): 30 | # Get event parameters 31 | bucket = event["Records"][0]["s3"]["bucket"]["name"] 32 | key = urllib.parse.unquote_plus( 33 | event["Records"][0]["s3"]["object"]["key"], encoding="utf-8" 34 | ) 35 | 36 | # Get object config file 37 | try: 38 | config_file = s3.get_object(Bucket=bucket, Key=key) 39 | except Exception as e: 40 | print(e) 41 | raise e 42 | return config_file 43 | 44 | 45 | def parse(config_file): 46 | # Parse yaml file 47 | try: 48 | parsed_config_file = yaml.safe_load(config_file["Body"]) 49 | except yaml.YAMLError as exc: 50 | return exc 51 | return parsed_config_file 52 | 53 | 54 | def add_account_information(config_file): 55 | for stackset in config_file["stacksets"]: 56 | stackset["account"] = config_file["account"] 57 | if "terminate" in config_file and config_file["terminate"]: 58 | stackset["terminate"] = config_file["terminate"] 59 | return config_file 60 | 61 | 62 | def trigger_step_function(config_file): 63 | # Start step function 64 | print( 65 | "Trigerring Step functions with these values: " 66 | + json.dumps(config_file, indent=2) 67 | ) 68 | response = step_functions.start_execution( 69 | stateMachineArn=STATE_MACHINE_ARN, input=json.dumps(config_file) 70 | ) 71 | return response 72 | 73 | 74 | def lambda_handler(event, context): 75 | config_file = get_config_file(event) 76 | config_file = parse(config_file) 77 | config_file = add_account_information(config_file) 78 | response = trigger_step_function(config_file) 79 | return response["executionArn"] 80 | -------------------------------------------------------------------------------- /lambda/01_trigger_step_function/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | requests 3 | pyyaml 4 | -------------------------------------------------------------------------------- /lambda/01_trigger_step_function/test_events/test_event.py: -------------------------------------------------------------------------------- 1 | test_event = { 2 | "Records": [ 3 | { 4 | "eventVersion": "2.1", 5 | "eventSource": "aws:s3", 6 | "awsRegion": "us-east-2", 7 | "eventTime": "2019-09-03T19:37:27.192Z", 8 | "eventName": "ObjectCreated:Put", 9 | "userIdentity": {"principalId": "AWS:AIDAINPONIXQXHT3IKHL2"}, 10 | "requestParameters": {"sourceIPAddress": "205.255.255.255"}, 11 | "responseElements": { 12 | "x-amz-request-id": "D82B88E5F771F645", 13 | "x-amz-id-2": "vlR7PnpV2Ce81l0PRw6jlUpck7Jo5ZsQjryTjKlc5aLWGVHPZLj5NeC6qMa0emYBDXOo6QBU0Wo=", 14 | }, 15 | "s3": { 16 | "s3SchemaVersion": "1.0", 17 | "configurationId": "828aa6fc-f7b5-4305-8584-487c791949c1", 18 | "bucket": { 19 | "name": "test-bucket", 20 | "ownerIdentity": {"principalId": "A3I5XTEXAMAI3E"}, 21 | "arn": "arn:aws:s3:::lambda-artifacts-deafc19498e3f2df", 22 | }, 23 | "object": { 24 | "key": "test-key", 25 | "size": 1305107, 26 | "eTag": "b21b84d653bb07b05b1e6b33684dc11b", 27 | "sequencer": "0C0F6F405D6ED209E1", 28 | }, 29 | }, 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /lambda/01_trigger_step_function/test_files/sample_account.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | account: '123456789876' 3 | stacksets: 4 | - name: vpc 5 | parameters: 6 | CidrBlock: '10.0.0.0/24' 7 | EnableDnsHostnames: "true" 8 | -------------------------------------------------------------------------------- /lambda/01_trigger_step_function/test_files/sample_account_terminate.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | account: '123456789876' 3 | terminate: True 4 | stacksets: 5 | - name: vpc 6 | parameters: 7 | CidrBlock: '10.0.0.0/24' 8 | EnableDnsHostnames: "true" 9 | -------------------------------------------------------------------------------- /lambda/01_trigger_step_function/test_trigger.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import json 3 | import os 4 | 5 | from botocore.stub import Stubber 6 | import pytest 7 | 8 | from fixtures import lambda_module 9 | from test_events.test_event import test_event 10 | 11 | __location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) 12 | 13 | lambda_module = pytest.fixture( 14 | scope="module", 15 | params=[ 16 | { 17 | "function_dir": "01_trigger_step_function", 18 | "module_name": "app", 19 | "environ": { 20 | "STATE_MACHINE": "step_function_test_arn", 21 | }, 22 | } 23 | ], 24 | )(lambda_module) 25 | 26 | 27 | def test_trigger_step_function(lambda_module): 28 | """ 29 | Given an account configuration object is pushed to an s3 bucket 30 | When the handler is called 31 | Then a step function is triggered, with the account configuration object contents as the step functions input 32 | """ 33 | # Given 34 | s3_object_mock_content = open( 35 | os.path.join(__location__, "test_files/sample_account.yaml"), "r" 36 | ).read() 37 | s3_expected_params = {"Bucket": "test-bucket", "Key": "test-key"} 38 | s3_response = {"Body": s3_object_mock_content} 39 | step_functions_expected_input = { 40 | "account": "123456789876", 41 | "stacksets": [ 42 | { 43 | "name": "vpc", 44 | "parameters": { 45 | "CidrBlock": "10.0.0.0/24", 46 | "EnableDnsHostnames": "true", 47 | }, 48 | "account": "123456789876", 49 | } 50 | ], 51 | } 52 | step_functions_expected_params = { 53 | "stateMachineArn": "step_function_test_arn", 54 | "input": json.dumps(step_functions_expected_input), 55 | } 56 | step_functions_response = { 57 | "executionArn": "execution_arn", 58 | "startDate": datetime(2010, 1, 1), 59 | } 60 | ## S3 mock configuration 61 | s3 = Stubber(lambda_module.s3) 62 | s3.add_response("get_object", s3_response, s3_expected_params) 63 | ## Step Functions mock configuration 64 | step_functions = Stubber(lambda_module.step_functions) 65 | step_functions.add_response( 66 | "start_execution", step_functions_response, step_functions_expected_params 67 | ) 68 | s3.activate() 69 | step_functions.activate() 70 | # When 71 | response = lambda_module.lambda_handler(test_event, {}) 72 | s3.deactivate() 73 | step_functions.deactivate() 74 | # Then 75 | assert response == "execution_arn" 76 | 77 | 78 | def test_trigger_step_function_terminate(lambda_module): 79 | """ 80 | Given an account configuration object with the terminate field set to True is pushed to an s3 bucket 81 | When the handler is called 82 | Then a step function is triggered, with the account configuration object contents as the step functions input 83 | """ 84 | # Given 85 | ## S3 mock configuration 86 | s3_object_mock_content = open( 87 | os.path.join(__location__, "test_files/sample_account_terminate.yaml"), "r" 88 | ).read() 89 | step_functions_expected_input = { 90 | "account": "123456789876", 91 | "terminate": True, 92 | "stacksets": [ 93 | { 94 | "name": "vpc", 95 | "parameters": { 96 | "CidrBlock": "10.0.0.0/24", 97 | "EnableDnsHostnames": "true", 98 | }, 99 | "account": "123456789876", 100 | "terminate": True, 101 | } 102 | ], 103 | } 104 | s3 = Stubber(lambda_module.s3) 105 | s3_expected_params = {"Bucket": "test-bucket", "Key": "test-key"} 106 | s3_response = {"Body": s3_object_mock_content} 107 | s3.add_response("get_object", s3_response, s3_expected_params) 108 | # Step Functions mock configuration 109 | step_functions = Stubber(lambda_module.step_functions) 110 | step_functions_expected_params = { 111 | "stateMachineArn": "step_function_test_arn", 112 | "input": json.dumps(step_functions_expected_input), 113 | } 114 | step_functions_response = { 115 | "executionArn": "execution_arn", 116 | "startDate": datetime(2010, 1, 1), 117 | } 118 | step_functions.add_response( 119 | "start_execution", step_functions_response, step_functions_expected_params 120 | ) 121 | s3.activate() 122 | step_functions.activate() 123 | # When 124 | response = lambda_module.lambda_handler(test_event, {}) 125 | s3.deactivate() 126 | step_functions.deactivate() 127 | # Then 128 | assert response == "execution_arn" 129 | -------------------------------------------------------------------------------- /lambda/02_create_update_delete_stack_instances/app.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | # the Software, and to permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | import os 17 | from random import randrange 18 | import time 19 | 20 | import boto3 21 | 22 | cloudformation = boto3.client("cloudformation") 23 | 24 | AWS_REGION = os.environ["AWS_REGION"] 25 | 26 | 27 | def format_parameters(parameters): 28 | formatted_parameters = [] 29 | for key, value in parameters.items(): 30 | formatted_parameters.append({"ParameterKey": key, "ParameterValue": value}) 31 | return formatted_parameters 32 | 33 | 34 | def stackinstance_exists(stackset_name, account_id, region): 35 | # Add jitter 36 | time.sleep(randrange(10, 20)) 37 | stack_instance_size = len( 38 | cloudformation.list_stack_instances( 39 | StackSetName=stackset_name, 40 | StackInstanceAccount=account_id, 41 | StackInstanceRegion=region, 42 | )["Summaries"] 43 | ) 44 | return True if stack_instance_size > 0 else False 45 | 46 | 47 | def lambda_handler(event, context): 48 | # Get stackset instance information 49 | account_id = str(event["account"]) 50 | terminate_stack_instance = event["terminate"] if "terminate" in event else False 51 | stackset_name = event["name"] 52 | parameter_overrides = ( 53 | format_parameters(event["parameters"]) if "parameters" in event else [] 54 | ) 55 | 56 | # Check if the operation is create, update or delete 57 | if terminate_stack_instance: 58 | operation_function = cloudformation.delete_stack_instances 59 | operation_arguments = { 60 | "StackSetName": stackset_name, 61 | "Accounts": [account_id], 62 | "RetainStacks": False, 63 | "Regions": [AWS_REGION], 64 | } 65 | else: 66 | operation_function = ( 67 | cloudformation.update_stack_instances 68 | if stackinstance_exists(stackset_name, account_id, AWS_REGION) 69 | else cloudformation.create_stack_instances 70 | ) 71 | operation_arguments = { 72 | "StackSetName": stackset_name, 73 | "Accounts": [account_id], 74 | "ParameterOverrides": parameter_overrides, 75 | "Regions": [AWS_REGION], 76 | } 77 | 78 | # Add jitter 79 | time.sleep(randrange(10, 20)) 80 | # Perform operation 81 | response = operation_function(**operation_arguments) 82 | print(response) 83 | 84 | # Update stackset instance in creation data 85 | event["stackset_instance_in_treatment"] = { 86 | "name": stackset_name, 87 | "account_id": account_id, 88 | } 89 | 90 | return event 91 | -------------------------------------------------------------------------------- /lambda/02_create_update_delete_stack_instances/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cloudformation-stackset-orchestration/0c46f8abc5bdc4db88218c34c6208ab4ba6d1653/lambda/02_create_update_delete_stack_instances/requirements.txt -------------------------------------------------------------------------------- /lambda/02_create_update_delete_stack_instances/test_create_update_delete.py: -------------------------------------------------------------------------------- 1 | from botocore.stub import Stubber 2 | import pytest 3 | 4 | from fixtures import lambda_module 5 | 6 | lambda_module = pytest.fixture( 7 | scope="module", 8 | params=[ 9 | { 10 | "function_dir": "02_create_update_delete_stack_instances", 11 | "module_name": "app", 12 | "environ": { 13 | "AWS_REGION": "eu-west-1", 14 | }, 15 | } 16 | ], 17 | )(lambda_module) 18 | 19 | 20 | def test_handler_create(lambda_module, monkeypatch): 21 | """ 22 | Given a setup function input for creating stack instances 23 | When the handler is called 24 | Then the CreateStackInstances action of the CloudFormation API is called with the stackset parameters 25 | """ 26 | # Given 27 | monkeypatch.setattr(lambda_module.time, "sleep", lambda _: None) 28 | stackset_name = "vpc" 29 | account_id = "123456789876" 30 | region = "eu-west-1" 31 | parameters = {"CidrBlock": "10.0.0.0/24", "EnableDnsHostnames": "true"} 32 | parameter_overrides = [ 33 | {"ParameterKey": "CidrBlock", "ParameterValue": "10.0.0.0/24"}, 34 | {"ParameterKey": "EnableDnsHostnames", "ParameterValue": "true"}, 35 | ] 36 | step_function_input = { 37 | "name": stackset_name, 38 | "parameters": parameters, 39 | "account": account_id, 40 | } 41 | expected_step_function_output = { 42 | "name": stackset_name, 43 | "parameters": parameters, 44 | "account": account_id, 45 | "stackset_instance_in_treatment": { 46 | "name": stackset_name, 47 | "account_id": account_id, 48 | }, 49 | } 50 | cloudformation_create_expected_params = { 51 | "StackSetName": stackset_name, 52 | "Accounts": [account_id], 53 | "ParameterOverrides": parameter_overrides, 54 | "Regions": [region], 55 | } 56 | cloudformation_create_response = {"OperationId": "operation-id"} 57 | cloudformation_list_expected_params = { 58 | "StackSetName": stackset_name, 59 | "StackInstanceAccount": account_id, 60 | "StackInstanceRegion": region, 61 | } 62 | cloudformation_list_response = {"Summaries": []} 63 | ## Cloudformation mock configuration 64 | cloudformation = Stubber(lambda_module.cloudformation) 65 | cloudformation.add_response( 66 | "list_stack_instances", 67 | cloudformation_list_response, 68 | cloudformation_list_expected_params, 69 | ) 70 | cloudformation.add_response( 71 | "create_stack_instances", 72 | cloudformation_create_response, 73 | cloudformation_create_expected_params, 74 | ) 75 | cloudformation.activate() 76 | # When 77 | response = lambda_module.lambda_handler(step_function_input, {}) 78 | cloudformation.deactivate() 79 | # Then 80 | assert response == expected_step_function_output 81 | 82 | 83 | def test_handler_update(lambda_module, monkeypatch): 84 | """ 85 | Given a setup function input for updating stack instances 86 | When the handler is called 87 | Then the UpdateStackInstances action of the CloudFormation API is called with the stackset parameters 88 | """ 89 | # Given 90 | monkeypatch.setattr(lambda_module.time, "sleep", lambda _: None) 91 | stackset_name = "vpc" 92 | account_id = "123456789876" 93 | region = "eu-west-1" 94 | parameters = {"CidrBlock": "10.0.0.0/24", "EnableDnsHostnames": "true"} 95 | parameter_overrides = [ 96 | {"ParameterKey": "CidrBlock", "ParameterValue": "10.0.0.0/24"}, 97 | {"ParameterKey": "EnableDnsHostnames", "ParameterValue": "true"}, 98 | ] 99 | step_function_input = { 100 | "name": stackset_name, 101 | "parameters": parameters, 102 | "account": account_id, 103 | } 104 | expected_step_function_output = { 105 | "name": stackset_name, 106 | "parameters": parameters, 107 | "account": account_id, 108 | "stackset_instance_in_treatment": { 109 | "name": stackset_name, 110 | "account_id": account_id, 111 | }, 112 | } 113 | cloudformation_create_expected_params = { 114 | "StackSetName": stackset_name, 115 | "Accounts": [account_id], 116 | "ParameterOverrides": parameter_overrides, 117 | "Regions": [region], 118 | } 119 | cloudformation_create_response = {"OperationId": "operation-id"} 120 | cloudformation_list_expected_params = { 121 | "StackSetName": stackset_name, 122 | "StackInstanceAccount": account_id, 123 | "StackInstanceRegion": region, 124 | } 125 | cloudformation_list_response = { 126 | "Summaries": [ 127 | { 128 | "StackSetId": "vpc:stackset-id", 129 | "Region": region, 130 | "Account": account_id, 131 | "Status": "CURRENT", 132 | "StatusReason": "StatusReason", 133 | "StackInstanceStatus": {"DetailedStatus": "SUCCESS"}, 134 | "OrganizationalUnitId": "", 135 | "DriftStatus": "NOT_CHECKED", 136 | } 137 | ] 138 | } 139 | ## Cloudformation mock configuration 140 | cloudformation = Stubber(lambda_module.cloudformation) 141 | cloudformation.add_response( 142 | "list_stack_instances", 143 | cloudformation_list_response, 144 | cloudformation_list_expected_params, 145 | ) 146 | cloudformation.add_response( 147 | "update_stack_instances", 148 | cloudformation_create_response, 149 | cloudformation_create_expected_params, 150 | ) 151 | cloudformation.activate() 152 | # When 153 | response = lambda_module.lambda_handler(step_function_input, {}) 154 | cloudformation.deactivate() 155 | # Then 156 | assert response == expected_step_function_output 157 | 158 | 159 | def test_handler_delete(lambda_module, monkeypatch): 160 | """ 161 | Given a setup function input for updating stack instances 162 | When the handler is called 163 | Then the DeleteStackInstances action of the CloudFormation API is called with the stackset parameters 164 | """ 165 | # Given 166 | monkeypatch.setattr(lambda_module.time, "sleep", lambda _: None) 167 | stackset_name = "vpc" 168 | account_id = "123456789876" 169 | region = "eu-west-1" 170 | parameters = {"CidrBlock": "10.0.0.0/24", "EnableDnsHostnames": "true"} 171 | step_function_input = { 172 | "name": stackset_name, 173 | "parameters": parameters, 174 | "account": account_id, 175 | "terminate": True, 176 | } 177 | expected_step_function_output = { 178 | "name": stackset_name, 179 | "parameters": parameters, 180 | "account": account_id, 181 | "terminate": True, 182 | "stackset_instance_in_treatment": { 183 | "name": stackset_name, 184 | "account_id": account_id, 185 | }, 186 | } 187 | cloudformation_expected_params = { 188 | "StackSetName": stackset_name, 189 | "Accounts": [account_id], 190 | "RetainStacks": False, 191 | "Regions": [region], 192 | } 193 | cloudformation_response = {"OperationId": "operation-id"} 194 | ## Cloudformation mock configuration 195 | cloudformation = Stubber(lambda_module.cloudformation) 196 | cloudformation.add_response( 197 | "delete_stack_instances", 198 | cloudformation_response, 199 | cloudformation_expected_params, 200 | ) 201 | cloudformation.activate() 202 | # When 203 | response = lambda_module.lambda_handler(step_function_input, {}) 204 | cloudformation.deactivate() 205 | # Then 206 | assert response == expected_step_function_output 207 | -------------------------------------------------------------------------------- /lambda/03_verify_stack_instance_creation/app.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | # the Software, and to permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | import json 17 | import os 18 | from random import randrange 19 | import time 20 | 21 | import boto3 22 | 23 | cloudformation = boto3.client("cloudformation") 24 | REGION = os.environ["AWS_REGION"] 25 | 26 | 27 | class StacksetCreationError(Exception): 28 | pass 29 | 30 | 31 | def check_stackset_instance_for_errors(stackset_instance): 32 | 33 | if "StatusReason" in stackset_instance: 34 | stackset_instance_status = stackset_instance["Status"] 35 | stackset_instance_status_reason = stackset_instance["StatusReason"] 36 | 37 | # Raise exception if there is an error while creating the stackset instance 38 | if ( 39 | stackset_instance_status == "OUTDATED" 40 | and "Error" in stackset_instance_status_reason 41 | ): 42 | raise StacksetCreationError(stackset_instance_status_reason) 43 | 44 | 45 | def stackset_instance_ready(stackset_name, account_id, region): 46 | 47 | # Add jitter 48 | time.sleep(randrange(10, 20)) 49 | # Get stackset instance information 50 | stackset_instance = cloudformation.list_stack_instances( 51 | StackSetName=stackset_name, 52 | StackInstanceAccount=account_id, 53 | StackInstanceRegion=region, 54 | ) 55 | print("Recovered stacket instance: ") 56 | print(stackset_instance) 57 | 58 | stackset_instance = stackset_instance["Summaries"][0] 59 | 60 | # Check for errors in stackset instance creation 61 | check_stackset_instance_for_errors(stackset_instance) 62 | 63 | stackset_instance_status = stackset_instance["Status"] 64 | 65 | return True if stackset_instance_status == "CURRENT" else False 66 | 67 | 68 | def lambda_handler(event, context): 69 | # Wait for stackset instance creation 70 | print("Waiting for stackset instance to be processed...") 71 | time.sleep(30) 72 | # Get stackset instance information 73 | print("Received event: " + json.dumps(event, indent=2)) 74 | stackset_instance = event["stackset_instance_in_treatment"] 75 | terminate_stack_instance = event["terminate"] if "terminate" in event else False 76 | stackset_name = stackset_instance["name"] 77 | account_id = str(stackset_instance["account_id"]) 78 | print("StackSet instance: " + json.dumps(stackset_instance, indent=2)) 79 | print("StackSet Name: " + stackset_name) 80 | print("AccountId: " + account_id) 81 | 82 | # Update stackset instance status, or fail the pipeline if there is an error 83 | try: 84 | event["stackset_instance_ready"] = ( 85 | True 86 | if terminate_stack_instance 87 | else stackset_instance_ready(stackset_name, account_id, REGION) 88 | ) 89 | except StacksetCreationError as e: 90 | print("StacksetCreationError") 91 | print(e) 92 | raise e 93 | 94 | print("Outgoing event: " + json.dumps(event, indent=2)) 95 | 96 | return event 97 | -------------------------------------------------------------------------------- /lambda/03_verify_stack_instance_creation/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cloudformation-stackset-orchestration/0c46f8abc5bdc4db88218c34c6208ab4ba6d1653/lambda/03_verify_stack_instance_creation/requirements.txt -------------------------------------------------------------------------------- /lambda/03_verify_stack_instance_creation/test_verify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from botocore.stub import Stubber 4 | 5 | from fixtures import lambda_module 6 | 7 | lambda_module = pytest.fixture( 8 | scope="module", 9 | params=[ 10 | { 11 | "function_dir": "03_verify_stack_instance_creation", 12 | "module_name": "app", 13 | "environ": { 14 | "AWS_REGION": "eu-west-1", 15 | }, 16 | } 17 | ], 18 | )(lambda_module) 19 | 20 | 21 | def test_verify_create_update_not_ready(lambda_module, monkeypatch): 22 | """ 23 | Given a setup function input for verifying stack instances, when the stackset instance is not ready 24 | When the handler is called 25 | Then handler return a dict with the stackset_instance_ready variable set to False 26 | """ 27 | # Given 28 | monkeypatch.setattr(lambda_module.time, "sleep", lambda _: None) 29 | stackset_name = "vpc" 30 | account_id = "123456789876" 31 | region = "eu-west-1" 32 | parameters = {"CidrBlock": "10.0.0.0/24", "EnableDnsHostnames": "true"} 33 | step_function_input = { 34 | "name": stackset_name, 35 | "parameters": parameters, 36 | "account": account_id, 37 | "stackset_instance_in_treatment": { 38 | "name": stackset_name, 39 | "account_id": account_id, 40 | }, 41 | } 42 | step_function_expected_output = { 43 | "name": stackset_name, 44 | "parameters": parameters, 45 | "account": account_id, 46 | "stackset_instance_in_treatment": { 47 | "name": stackset_name, 48 | "account_id": account_id, 49 | }, 50 | "stackset_instance_ready": False, 51 | } 52 | cloudformation_list_expected_params = { 53 | "StackSetName": stackset_name, 54 | "StackInstanceAccount": account_id, 55 | "StackInstanceRegion": region, 56 | } 57 | cloudformation_list_response = { 58 | "Summaries": [ 59 | { 60 | "StackSetId": "vpc:stackset-id", 61 | "Region": region, 62 | "Account": account_id, 63 | "Status": "OUTDATED", 64 | "StatusReason": "StatusReason", 65 | "StackInstanceStatus": {"DetailedStatus": "SUCCESS"}, 66 | "OrganizationalUnitId": "", 67 | "DriftStatus": "NOT_CHECKED", 68 | } 69 | ] 70 | } 71 | ## Cloudformation mock configuration 72 | cloudformation = Stubber(lambda_module.cloudformation) 73 | cloudformation.add_response( 74 | "list_stack_instances", 75 | cloudformation_list_response, 76 | cloudformation_list_expected_params, 77 | ) 78 | cloudformation.activate() 79 | # When 80 | response = lambda_module.lambda_handler(step_function_input, {}) 81 | cloudformation.deactivate() 82 | # Then 83 | assert response == step_function_expected_output 84 | 85 | 86 | def test_verify_create_update_ready(lambda_module, monkeypatch): 87 | """ 88 | Given a setup function input for verifying stack instances, when the stackset instance is ready 89 | When the handler is called 90 | Then handler return a dict with the stackset_instance_ready variable set to True 91 | """ 92 | # Given 93 | monkeypatch.setattr(lambda_module.time, "sleep", lambda _: None) 94 | stackset_name = "vpc" 95 | account_id = "123456789876" 96 | region = "eu-west-1" 97 | parameters = {"CidrBlock": "10.0.0.0/24", "EnableDnsHostnames": "true"} 98 | step_function_input = { 99 | "name": stackset_name, 100 | "parameters": parameters, 101 | "account": account_id, 102 | "stackset_instance_in_treatment": { 103 | "name": stackset_name, 104 | "account_id": account_id, 105 | }, 106 | } 107 | step_function_expected_output = { 108 | "name": stackset_name, 109 | "parameters": parameters, 110 | "account": account_id, 111 | "stackset_instance_in_treatment": { 112 | "name": stackset_name, 113 | "account_id": account_id, 114 | }, 115 | "stackset_instance_ready": True, 116 | } 117 | cloudformation_list_expected_params = { 118 | "StackSetName": stackset_name, 119 | "StackInstanceAccount": account_id, 120 | "StackInstanceRegion": region, 121 | } 122 | cloudformation_list_response = { 123 | "Summaries": [ 124 | { 125 | "StackSetId": "vpc:stackset-id", 126 | "Region": region, 127 | "Account": account_id, 128 | "Status": "CURRENT", 129 | "StatusReason": "StatusReason", 130 | "StackInstanceStatus": {"DetailedStatus": "SUCCESS"}, 131 | "OrganizationalUnitId": "", 132 | "DriftStatus": "NOT_CHECKED", 133 | } 134 | ] 135 | } 136 | ## Cloudformation mock configuration 137 | cloudformation = Stubber(lambda_module.cloudformation) 138 | cloudformation.add_response( 139 | "list_stack_instances", 140 | cloudformation_list_response, 141 | cloudformation_list_expected_params, 142 | ) 143 | cloudformation.activate() 144 | # When 145 | response = lambda_module.lambda_handler(step_function_input, {}) 146 | cloudformation.deactivate() 147 | # Then 148 | assert response == step_function_expected_output 149 | 150 | 151 | def test_verify_delete(lambda_module, monkeypatch): 152 | """ 153 | Given a setup function input for verifying the deletion stack instances 154 | When the handler is called 155 | Then handler return a dict with the stackset_instance_ready variable set to True 156 | """ 157 | # Given 158 | monkeypatch.setattr(lambda_module.time, "sleep", lambda _: None) 159 | stackset_name = "vpc" 160 | account_id = "123456789876" 161 | region = "eu-west-1" 162 | parameters = {"CidrBlock": "10.0.0.0/24", "EnableDnsHostnames": "true"} 163 | step_function_input = { 164 | "name": stackset_name, 165 | "parameters": parameters, 166 | "account": account_id, 167 | "terminate": True, 168 | "stackset_instance_in_treatment": { 169 | "name": stackset_name, 170 | "account_id": account_id, 171 | }, 172 | } 173 | step_function_expected_output = { 174 | "name": stackset_name, 175 | "parameters": parameters, 176 | "account": account_id, 177 | "terminate": True, 178 | "stackset_instance_in_treatment": { 179 | "name": stackset_name, 180 | "account_id": account_id, 181 | }, 182 | "stackset_instance_ready": True, 183 | } 184 | cloudformation_list_expected_params = { 185 | "StackSetName": stackset_name, 186 | "StackInstanceAccount": account_id, 187 | "StackInstanceRegion": region, 188 | } 189 | cloudformation_list_response = { 190 | "Summaries": [ 191 | { 192 | "StackSetId": "vpc:stackset-id", 193 | "Region": region, 194 | "Account": account_id, 195 | "Status": "OUTDATED", 196 | "StatusReason": "StatusReason", 197 | "StackInstanceStatus": {"DetailedStatus": "SUCCESS"}, 198 | "OrganizationalUnitId": "", 199 | "DriftStatus": "NOT_CHECKED", 200 | } 201 | ] 202 | } 203 | ## Cloudformation mock configuration 204 | cloudformation = Stubber(lambda_module.cloudformation) 205 | cloudformation.add_response( 206 | "list_stack_instances", 207 | cloudformation_list_response, 208 | cloudformation_list_expected_params, 209 | ) 210 | cloudformation.activate() 211 | # When 212 | response = lambda_module.lambda_handler(step_function_input, {}) 213 | cloudformation.deactivate() 214 | # Then 215 | assert response == step_function_expected_output 216 | -------------------------------------------------------------------------------- /shared/fixtures.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import sys 4 | 5 | 6 | def lambda_module(request): 7 | """ 8 | Main module of the Lambda function 9 | 10 | This also load environment variables and the path to the Lambda function 11 | prior to loading the module itself. 12 | 13 | To use this within a test module, do: 14 | 15 | from fixtures import lambda_module 16 | lambda_module = pytest.fixture(scope="module", params=[{ 17 | "function_dir": "function_dir", 18 | "module_name": "main", 19 | "environ": { 20 | "ENVIRONMENT": "test", 21 | "EVENT_BUS_NAME": "EVENT_BUS_NAME", 22 | "POWERTOOLS_TRACE_DISABLED": "true" 23 | } 24 | }])(lambda_module) 25 | """ 26 | 27 | # Inject environment variables 28 | backup_environ = {} 29 | for key, value in request.param.get("environ", {}).items(): 30 | if key in os.environ: 31 | backup_environ[key] = os.environ[key] 32 | os.environ[key] = value 33 | 34 | # Add path for Lambda function 35 | sys.path.insert( 36 | 0, 37 | os.path.join( 38 | os.environ["LAMBDA_DIR"], 39 | request.param["function_dir"], 40 | ), 41 | ) 42 | 43 | # Save the list of previously loaded modules 44 | prev_modules = list(sys.modules.keys()) 45 | 46 | # Return the function module 47 | module = importlib.import_module(request.param["module_name"]) 48 | yield module 49 | 50 | # Delete newly loaded modules 51 | new_keys = list(sys.modules.keys()) 52 | for key in new_keys: 53 | if key not in prev_modules: 54 | del sys.modules[key] 55 | 56 | # Delete function module 57 | del module 58 | 59 | # Remove the Lambda function from path 60 | sys.path.pop(0) 61 | 62 | # Restore environment variables 63 | for key in request.param.get("environ", {}).keys(): 64 | if key in backup_environ: 65 | os.environ[key] = backup_environ[key] 66 | else: 67 | del os.environ[key] 68 | -------------------------------------------------------------------------------- /stackset-examples/vpc.yaml: -------------------------------------------------------------------------------- 1 | Parameters: 2 | CidrBlock: 3 | Type: String 4 | Description: The CIDR block to use for VPC creation. 5 | Default: 10.0.0.0/16 6 | EnableDnsHostnames: 7 | Type: String 8 | Description: Enable DNS Hostnames on the VPC. 9 | Default: "true" 10 | AllowedValues: 11 | - "true" 12 | - "false" 13 | EnableDnsSupport: 14 | Type: String 15 | Description: Enable DNS support on the VPC. 16 | AllowedValues: 17 | - "true" 18 | - "false" 19 | Default: "true" 20 | 21 | Resources: 22 | VPC: 23 | Type: AWS::EC2::VPC 24 | Properties: 25 | CidrBlock: !Ref CidrBlock 26 | EnableDnsHostnames: !Ref EnableDnsHostnames 27 | EnableDnsSupport: !Ref EnableDnsSupport 28 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | 4 | Parameters: 5 | StackSetAdministratorPrincipal: 6 | Type: String 7 | Description: The ARN of the AWS principal allowed to add StackSet configuration files to the StackSet configuration bucket. 8 | 9 | Resources: 10 | # S3 Bucket for account document storage 11 | AccountBucket: 12 | Type: AWS::S3::Bucket 13 | Properties: 14 | BucketName: !Sub "stackset-orchestration-bucket-${AWS::AccountId}" 15 | AccessControl: Private 16 | PublicAccessBlockConfiguration: 17 | BlockPublicAcls: true 18 | BlockPublicPolicy: true 19 | IgnorePublicAcls: true 20 | RestrictPublicBuckets: true 21 | 22 | StackSetAdministratorRole: 23 | Type: "AWS::IAM::Role" 24 | Properties: 25 | RoleName: StackSetAdministrator 26 | AssumeRolePolicyDocument: 27 | Version: 2012-10-17 28 | Statement: 29 | - Effect: Allow 30 | Principal: 31 | AWS: 32 | - !Ref StackSetAdministratorPrincipal 33 | Action: 34 | - 'sts:AssumeRole' 35 | Policies: 36 | - PolicyName: AddConfigurationFilePermissions 37 | PolicyDocument: 38 | Version: 2012-10-17 39 | Statement: 40 | - Effect: Allow 41 | Action: 42 | - s3:ListBucket 43 | Resource: 44 | - !Sub "arn:aws:s3:::${AccountBucket}" 45 | - Effect: Allow 46 | Action: 47 | - s3:PutObject 48 | - s3:DeleteObject 49 | Resource: 50 | - !Sub "arn:aws:s3:::${AccountBucket}/*" 51 | 52 | # Parse YAML file and trigger the Step Function pipeline with it 53 | TriggerStepFunction: 54 | Type: AWS::Serverless::Function 55 | Properties: 56 | CodeUri: lambda/01_trigger_step_function/ 57 | Environment: 58 | Variables: 59 | STATE_MACHINE: !Sub 'arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:${StackSetOrchestrationStateMachine.Name}' 60 | Handler: app.lambda_handler 61 | Runtime: python3.7 62 | Policies: 63 | - S3ReadPolicy: 64 | BucketName: !Sub "stackset-orchestration-bucket-${AWS::AccountId}" 65 | - StepFunctionsExecutionPolicy: 66 | StateMachineName: !GetAtt StackSetOrchestrationStateMachine.Name 67 | Events: 68 | ObjectCreation: 69 | Type: S3 # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#s3 70 | Properties: 71 | Bucket: !Ref AccountBucket 72 | Events: s3:ObjectCreated:* 73 | 74 | # Create a StackSet instance on the desired account, continue the next step with the remaining StackSets 75 | CreateUpdateDeleteStackInstances: 76 | Type: AWS::Serverless::Function 77 | Properties: 78 | CodeUri: lambda/02_create_update_delete_stack_instances/ 79 | Handler: app.lambda_handler 80 | Runtime: python3.7 81 | Role: !GetAtt StackInstancesRole.Arn 82 | Timeout: 110 83 | 84 | # Verify that a StackSet instance has been created on the specified account before moving to the next one 85 | VerifyStackInstanceStatus: 86 | Type: AWS::Serverless::Function 87 | Properties: 88 | CodeUri: lambda/03_verify_stack_instance_creation/ 89 | Handler: app.lambda_handler 90 | Runtime: python3.7 91 | Role: !GetAtt StackInstancesRole.Arn 92 | Timeout: 110 93 | 94 | # Role definition for all lambda functions which interact with StackSets 95 | StackInstancesRole: 96 | Type: AWS::IAM::Role 97 | Properties: 98 | AssumeRolePolicyDocument: 99 | Version: 2012-10-17 100 | Statement: 101 | - Effect: Allow 102 | Action: sts:AssumeRole 103 | Principal: 104 | Service: lambda.amazonaws.com 105 | ManagedPolicyArns: 106 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 107 | Policies: 108 | - PolicyName: AssumeCFRole 109 | PolicyDocument: 110 | Version: 2012-10-17 111 | Statement: 112 | - Effect: Allow 113 | Action: sts:AssumeRole 114 | Resource: arn:aws:iam::aws:policy/aws-service-role/CloudFormationStackSetsOrgAdminServiceRolePolicy 115 | - Effect: Allow 116 | Action: 117 | - cloudformation:CreateStackInstances 118 | - cloudformation:DescribeStackInstance 119 | - cloudformation:DescribeStackSetOperation 120 | - cloudformation:ListStackInstances 121 | - cloudformation:UpdateStackInstances 122 | - cloudformation:DescribeStackSet 123 | - cloudformation:DeleteStackInstances 124 | Resource: !Sub arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stackset/*:* 125 | - Effect: Allow 126 | Action: organizations:DescribeAccount 127 | Resource: '*' 128 | 129 | StackSetOrchestrationStateMachine: 130 | Type: AWS::Serverless::StateMachine 131 | Properties: 132 | Name: !Sub "stackset-orchestration-state-machine-${AWS::AccountId}" 133 | Definition: 134 | StartAt: CreateStackSetInstances 135 | States: 136 | CreateStackSetInstances: 137 | Type: Map 138 | ItemsPath: $.stacksets 139 | Iterator: 140 | StartAt: CreateUpdateDeleteStackInstances 141 | States: 142 | CreateUpdateDeleteStackInstances: 143 | Type: Task 144 | Resource: !GetAtt CreateUpdateDeleteStackInstances.Arn 145 | Retry: 146 | - ErrorEquals: 147 | - OperationInProgressException 148 | - ClientError 149 | IntervalSeconds: 120 150 | BackoffRate: 1.1 151 | MaxAttempts: 30 152 | Next: VerifyStackInstanceStatus 153 | VerifyStackInstanceStatus: 154 | Type: Task 155 | Resource: !GetAtt VerifyStackInstanceStatus.Arn 156 | Retry: 157 | - ErrorEquals: 158 | - OperationInProgressException 159 | - ClientError 160 | IntervalSeconds: 120 161 | BackoffRate: 1.1 162 | MaxAttempts: 30 163 | Next: IsStackSetInstanceReady 164 | IsStackSetInstanceReady: 165 | Type: Choice 166 | Choices: 167 | - Variable: $.stackset_instance_ready 168 | BooleanEquals: false 169 | Next: VerifyStackInstanceStatus 170 | Default: Done 171 | Done: 172 | Type: Pass 173 | End: true 174 | End: true 175 | Role: !GetAtt StatesExecutionRole.Arn 176 | 177 | # Role definition for the State Machine execution 178 | StatesExecutionRole: 179 | Type: AWS::IAM::Role 180 | Properties: 181 | AssumeRolePolicyDocument: 182 | Version: 2012-10-17 183 | Statement: 184 | - Effect: Allow 185 | Principal: 186 | Service: 187 | - states.amazonaws.com 188 | Action: 'sts:AssumeRole' 189 | Policies: 190 | - PolicyName: StatesExecutionPolicy 191 | PolicyDocument: 192 | Version: 2012-10-17 193 | Statement: 194 | - Effect: Allow 195 | Action: 196 | - 'lambda:InvokeFunction' 197 | Resource: 198 | - !GetAtt CreateUpdateDeleteStackInstances.Arn 199 | - !GetAtt VerifyStackInstanceStatus.Arn 200 | - Effect: "Allow" 201 | Action: 202 | - "logs:CreateLogDelivery" 203 | - "logs:GetLogDelivery" 204 | - "logs:UpdateLogDelivery" 205 | - "logs:DeleteLogDelivery" 206 | - "logs:ListLogDeliveries" 207 | - "logs:PutResourcePolicy" 208 | - "logs:DescribeResourcePolicies" 209 | - "logs:DescribeLogGroups" 210 | Resource: 211 | - "*" 212 | 213 | Outputs: 214 | TriggerStepFunction: 215 | Description: "TriggerStepFunction Lambda Function ARN" 216 | Value: !GetAtt TriggerStepFunction.Arn 217 | TriggerStepFunctionIamRole: 218 | Description: "Implicit IAM Role created for TriggerStepFunction function" 219 | Value: !GetAtt TriggerStepFunctionRole.Arn 220 | --------------------------------------------------------------------------------