├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config-template.yml ├── configuration-examples ├── config - default and ou deployment.yml ├── config - default deployment.yml ├── update - multiple accounts and ou.yml └── update - singe account.yml ├── control-tower-account-factory-solution.yml ├── src ├── common.py ├── handler.py └── requirements.txt └── update-template.yml /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Customizing the AWS Control Tower account factory with AWS Lambda and AWS Service Catalog 2 | 3 | This solution is a part of the blog post “Customizing the AWS Control Tower account factory with AWS Lambda and AWS Service Catalog” 4 | 5 | ## Content 6 | 7 | __control-tower-account-factory-solution.yml__ – AWS CloudFormation template to deploy solution. 8 | 9 | __src__ – AWS Lambda function code. 10 | 11 | __config-template.yml__ – Products deployment template for new accounts. 12 | 13 | __update-template.yml__ - Products update template. 14 | 15 | __configuration-examples__ - Configuration examples 16 | 17 | 18 | ## Security 19 | 20 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 21 | 22 | ## License 23 | 24 | This library is licensed under the MIT-0 License. See the LICENSE file. 25 | 26 | -------------------------------------------------------------------------------- /config-template.yml: -------------------------------------------------------------------------------- 1 | # List of organization units 2 | organization_units: 3 | : # organization unit name where to deploy products 4 | # list of the AWS Service Catalog products 5 | products: 6 | - product_name: # name of the product 7 | product_version: <(optional) product version # optional value. If omitted the latest version of product will be deployed 8 | portfolio_name: # name of the portfolio 9 | provision_name: # name use by AWS Service Catalog to provision product 10 | dependson: # optional: list of the provision names that must to be deployed before product 11 | - 12 | parameters: # optional: list of parameters and values 13 | - Key: 14 | Value: 15 | regions: # list of AWS regions where to deploy product 16 | - 17 | # products listed under default OU will be deploy to every new account 18 | default: 19 | # list of the AWS Service Catalog products 20 | products: 21 | - product_name: # name of the product 22 | product_version: <(optional) product version # optional value. If omitted the latest version of product will be deployed 23 | portfolio_name: # name of the portfolio 24 | provision_name: # name use by AWS Service Catalog to provision product 25 | dependson: # optional: list of the provision names that have to be deploy before product 26 | - 27 | parameters: # optional: list of parameters and values 28 | - Key: 29 | Value: 30 | regions: # list of AWS regions where to deploy product 31 | - 32 | # optional: overwrite default value for maximum iteration 33 | max_iterations: 34 | -------------------------------------------------------------------------------- /configuration-examples/config - default and ou deployment.yml: -------------------------------------------------------------------------------- 1 | organization_units: 2 | Developers: 3 | products: 4 | - product_name: sc-kms-product 5 | portfolio_name: security-products 6 | provision_name: efs-kms-key 7 | parameters: 8 | - Key: KeyAlies 9 | Value: efs-kms-key 10 | regions: 11 | - us-east-1 12 | - us-east-2 13 | - product_name: sc-efs-product 14 | portfolio_name: security-products 15 | provision_name: sc-efs 16 | dependson: 17 | - efs-kms-key 18 | parameters: 19 | - Key: KMSId 20 | Value: 'alias/efs-kms-key' 21 | regions: 22 | - us-east-1 23 | - us-east-2 24 | default: 25 | products: 26 | - product_name: sc-governance-lambdas-product 27 | portfolio_name: security-products 28 | provision_name: sc-governance-lambdas-us-east-1 29 | dependson: 30 | - sc-governance-lambda-roles 31 | parameters: 32 | - Key: DeploymentBucketName 33 | Value: my-deployment-bucket-in-us-east-1 34 | regions: 35 | - us-east-1 36 | - product_name: sc-governance-lambdas-product 37 | portfolio_name: security-products 38 | provision_name: sc-governance-lambdas-us-east-2 39 | dependson: 40 | - sc-governance-lambda-roles 41 | parameters: 42 | - Key: DeploymentBucketName 43 | Value: my-deployment-bucket-in-us-east-2 44 | regions: 45 | - us-east-2 46 | - product_name: sc-governance-lambda-roles-product 47 | portfolio_name: security-products 48 | provision_name: sc-governance-lambda-roles 49 | regions: 50 | - us-east-1 51 | 52 | max_iterations: 40 53 | 54 | -------------------------------------------------------------------------------- /configuration-examples/config - default deployment.yml: -------------------------------------------------------------------------------- 1 | organization_units: 2 | default: 3 | products: 4 | - product_name: sc-governance-lambdas-product 5 | portfolio_name: security-products 6 | provision_name: sc-governance-lambdas-us-east-1 7 | dependson: 8 | - sc-governance-lambda-roles 9 | parameters: 10 | - Key: DeploymentBucketName 11 | Value: my-deployment-bucket-in-us-east-1 12 | regions: 13 | - us-east-1 14 | - product_name: sc-governance-lambdas-product 15 | portfolio_name: security-products 16 | provision_name: sc-governance-lambdas-us-east-2 17 | dependson: 18 | - sc-governance-lambda-roles 19 | parameters: 20 | - Key: DeploymentBucketName 21 | Value: my-deployment-bucket-in-us-east-2 22 | regions: 23 | - us-east-2 24 | - product_name: sc-governance-lambda-roles-product 25 | portfolio_name: security-products 26 | provision_name: sc-governance-lambda-roles 27 | regions: 28 | - us-east-1 29 | max_iterations: 40 30 | 31 | -------------------------------------------------------------------------------- /configuration-examples/update - multiple accounts and ou.yml: -------------------------------------------------------------------------------- 1 | products: 2 | - product_name: sc-governance-lambdas-product 3 | portfolio_name: security-products 4 | provision_name: sc-governance-lambdas-us-east-1 5 | dependson: 6 | - sc-governance-lambda-roles 7 | parameters: 8 | - Key: DeploymentBucketName 9 | Value: my-deployment-bucket-in-us-east-1 10 | regions: 11 | - us-east-1 12 | accounts: 13 | - 'my account id' 14 | - 'my account id' 15 | organization_units: 16 | - Developers 17 | - Sandboxes 18 | deployifnotexist: true 19 | - product_name: sc-governance-lambdas-product 20 | portfolio_name: security-products 21 | provision_name: sc-governance-lambdas-us-east-2 22 | dependson: 23 | - sc-governance-lambda-roles 24 | parameters: 25 | - Key: DeploymentBucketName 26 | Value: my-deployment-bucket-in-us-east-2 27 | regions: 28 | - us-east-2 29 | accounts: 30 | - 'my account id' 31 | - 'my account id' 32 | organization_units: 33 | - Developers 34 | - Sandboxes 35 | deployifnotexist: true 36 | 37 | max_iterations: 40 38 | 39 | -------------------------------------------------------------------------------- /configuration-examples/update - singe account.yml: -------------------------------------------------------------------------------- 1 | products: 2 | - product_name: sc-governance-lambdas-product 3 | portfolio_name: security-products 4 | provision_name: sc-governance-lambdas-us-east-1 5 | dependson: 6 | - sc-governance-lambda-roles 7 | parameters: 8 | - Key: DeploymentBucketName 9 | Value: my-deployment-bucket-in-us-east-1 10 | regions: 11 | - us-east-1 12 | accounts: 13 | - 'my account id' 14 | deployifnotexist: true 15 | - product_name: sc-governance-lambdas-product 16 | portfolio_name: security-products 17 | provision_name: sc-governance-lambdas-us-east-2 18 | dependson: 19 | - sc-governance-lambda-roles 20 | parameters: 21 | - Key: DeploymentBucketName 22 | Value: my-deployment-bucket-in-us-east-2 23 | regions: 24 | - us-east-2 25 | accounts: 26 | - 'my account id' 27 | deployifnotexist: true 28 | 29 | max_iterations: 40 30 | 31 | -------------------------------------------------------------------------------- /control-tower-account-factory-solution.yml: -------------------------------------------------------------------------------- 1 | # * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # * SPDX-License-Identifier: MIT-0 3 | # * 4 | # * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | # * software and associated documentation files (the "Software"), to deal in the Software 6 | # * without restriction, including without limitation the rights to use, copy, modify, 7 | # * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | # * permit persons to whom the Software is furnished to do so. 9 | # * 10 | # * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | # * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | # * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | # * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | # * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | # * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | 17 | AWSTemplateFormatVersion: 2010-09-09 18 | Description: AWS Control Tower Account Factory Solution Deployment Template 19 | Parameters: 20 | CreateConfigurationBucket: 21 | Description: Do you want to create configuration Amazon S3 bucket true|false 22 | Type: String 23 | Default: true 24 | AllowedValues: 25 | - true 26 | - false 27 | ConfigurationBucketName: 28 | Description: Name of Amazon S3 Configuration Bucket 29 | Type: String 30 | ConfigurationFileName: 31 | Description: Name and prefix of the configuration file 32 | Type: String 33 | Default: config.yml 34 | UpdateFileName: 35 | Description: Name and prefix of the update file 36 | Type: String 37 | Default: update.yml 38 | SourceCodeBucketName: 39 | Description: Name of Amazon S3 Bucket with lambda zipped source code 40 | Type: String 41 | SourceCodePackageName: 42 | Description: Name of code package including prefix 43 | Type: String 44 | Default: control-tower-account-factory-solution.zip 45 | FunctionName: 46 | Description: Account Factory AWS Lambda Function Name 47 | Type: String 48 | Default: control-tower-account-factory-lambda 49 | FunctionRoleName: 50 | Description: Account Factory AWS Lambda Function Role Name 51 | Type: String 52 | Default: control-tower-account-factory-lambda-role 53 | StateMachineName: 54 | Description: Name of AWS Step Function State Machine 55 | Type: String 56 | Default: control-tower-account-factory-state-machine 57 | StateMachineRoleName: 58 | Description: Name of AWS Step Function State Machine IAM Role 59 | Type: String 60 | Default: control-tower-account-factory-state-machine-role 61 | MaxIterations: 62 | Description: Maximum number of iteration for step funtion before report error 63 | Type: Number 64 | Default: 30 65 | TrackingTableName: 66 | Description: Name of the Amazon DynamoDB table to track product deployments and updates 67 | Type: String 68 | Default: control-tower-account-factory-tracking-table 69 | TopicName: 70 | Description: Name of SNS Topic to send notification 71 | Type: String 72 | Default: control-tower-account-factory-notification 73 | NotificationEmail: 74 | Description: Email address where to send notification 75 | Type: String 76 | Default: '' 77 | 78 | Conditions: 79 | CreateConfigurationBucket: !Equals [!Ref CreateConfigurationBucket, 'true'] 80 | CreateNotification: !Not [!Equals [!Ref NotificationEmail, '']] 81 | 82 | Resources: 83 | 84 | AccountFactoryLambdaRole: 85 | Type: 'AWS::IAM::Role' 86 | Properties: 87 | RoleName: !Ref FunctionRoleName 88 | AssumeRolePolicyDocument: 89 | Version: 2012-10-17 90 | Statement: 91 | - Effect: Allow 92 | Principal: 93 | Service: 94 | - lambda.amazonaws.com 95 | Action: 96 | - 'sts:AssumeRole' 97 | Path: / 98 | ManagedPolicyArns: 99 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 100 | Policies: 101 | - 102 | PolicyName: "Lambda" 103 | PolicyDocument: 104 | Version: "2012-10-17" 105 | Statement: 106 | - 107 | Effect: "Allow" 108 | Action: 109 | - servicecatalog:List* 110 | - servicecatalog:Search* 111 | - servicecatalog:Describe* 112 | - servicecatalog:UpdateConstraint 113 | - servicecatalog:CreateConstraint 114 | - servicecatalog:ProvisionProduct 115 | - servicecatalog:AssociatePrincipalWithPortfolio 116 | - servicecatalog:DeleteConstraint 117 | - servicecatalog:UpdateProvisionedProduct 118 | - organizations:ListRoots 119 | - organizations:DescribeAccount 120 | - organizations:ListChildren 121 | - organizations:DescribeOrganization 122 | - organizations:DescribeOrganizationalUnit 123 | - organizations:ListAccountsForParent 124 | - organizations:ListOrganizationalUnitsForParent 125 | - organizations:ListParents 126 | - states:SendTaskSuccess 127 | - states:StartExecution 128 | - states:SendTaskFailure 129 | - cloudformation:CreateStackSet 130 | - cloudformation:CreateStackInstances 131 | - cloudformation:Describe* 132 | - cloudformation:List* 133 | - cloudformation:UpdateStackSet 134 | - kms:Decrypt 135 | - kms:Encrypt 136 | - kms:Decrypt 137 | - kms:ReEncrypt* 138 | - kms:GenerateDataKey* 139 | - iam:GetRole 140 | - s3:GetObject 141 | Resource: "*" 142 | - 143 | Effect: Allow 144 | Action: 145 | - sns:Publish 146 | Resource: !Sub 'arn:aws:sns:${AWS::Region}:${AWS::AccountId}:${TopicName}' 147 | - 148 | Effect: Allow 149 | Action: 150 | - iam:PassRole 151 | Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/service-role/AWSControlTowerStackSetRole' 152 | - 153 | Effect: Allow 154 | Action: 155 | - dynamodb:PutItem 156 | - dynamodb:UpdateItem 157 | - dynamodb:Scan 158 | Resource: !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TrackingTableName}' 159 | 160 | AccountFactoryStateMachineRole: 161 | Type: 'AWS::IAM::Role' 162 | Properties: 163 | RoleName: !Ref StateMachineRoleName 164 | AssumeRolePolicyDocument: 165 | Version: 2012-10-17 166 | Statement: 167 | - Effect: Allow 168 | Principal: 169 | Service: 170 | - states.amazonaws.com 171 | Action: 172 | - 'sts:AssumeRole' 173 | Path: / 174 | Policies: 175 | - 176 | PolicyName: "StateMachine" 177 | PolicyDocument: 178 | Version: "2012-10-17" 179 | Statement: 180 | - 181 | Effect: Allow 182 | Action: 183 | - xray:PutTraceSegments 184 | - xray:PutTelemetryRecords 185 | - xray:GetSamplingRules 186 | - xray:GetSamplingTargets 187 | Resource: "*" 188 | - 189 | Effect: Allow 190 | Action: 191 | - logs:CreateLogDelivery 192 | - logs:GetLogDelivery 193 | - logs:UpdateLogDelivery 194 | - logs:DeleteLogDelivery 195 | - logs:ListLogDeliveries 196 | - logs:PutResourcePolicy 197 | - logs:DescribeResourcePolicies 198 | - logs:DescribeLogGroups 199 | Resource: "*" 200 | - 201 | Effect: Allow 202 | Action: 203 | - lambda:InvokeFunction 204 | Resource: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${FunctionName}' 205 | 206 | TrackingTable: 207 | Type: AWS::DynamoDB::Table 208 | Properties: 209 | AttributeDefinitions: 210 | - 211 | AttributeName: "ProvisionName" 212 | AttributeType: "S" 213 | - 214 | AttributeName: "Date" 215 | AttributeType: "S" 216 | KeySchema: 217 | - 218 | AttributeName: "ProvisionName" 219 | KeyType: "HASH" 220 | - 221 | AttributeName: "Date" 222 | KeyType: "RANGE" 223 | ProvisionedThroughput: 224 | ReadCapacityUnits: 5 225 | WriteCapacityUnits: 5 226 | TableName: !Ref TrackingTableName 227 | 228 | AccountFactoryFunction: 229 | Type: AWS::Lambda::Function 230 | Properties: 231 | FunctionName: !Ref FunctionName 232 | Description: "AWS Custom Control Tower Lifecycle event Lambda function to customize new accounts" 233 | Handler: handler.lambda_handler 234 | Role: !GetAtt AccountFactoryLambdaRole.Arn 235 | Environment: 236 | Variables: 237 | configuration_bucket_name: !Ref ConfigurationBucketName 238 | configuration_file: !Ref ConfigurationFileName 239 | update_file: !Ref UpdateFileName 240 | lambda_role: !GetAtt AccountFactoryLambdaRole.Arn 241 | max_iterations: !Ref MaxIterations 242 | state_machine_name: !Ref StateMachineName 243 | notification_topic: !If [CreateNotification , !Ref TopicName, "none"] 244 | track_table: !Ref TrackingTableName 245 | Code: 246 | S3Bucket: !Ref SourceCodeBucketName 247 | S3Key: !Ref SourceCodePackageName 248 | Runtime: python3.8 249 | Timeout: 900 250 | ReservedConcurrentExecutions: 10 251 | 252 | PermissionS3ToLambda: 253 | Type: AWS::Lambda::Permission 254 | Properties: 255 | FunctionName: !Ref AccountFactoryFunction 256 | Action: "lambda:InvokeFunction" 257 | Principal: "s3.amazonaws.com" 258 | SourceAccount: !Ref "AWS::AccountId" 259 | SourceArn: !Sub 'arn:aws:s3:::${ConfigurationBucketName}' 260 | 261 | S3Bucket: 262 | Type: AWS::S3::Bucket 263 | Condition: CreateConfigurationBucket 264 | DependsOn: PermissionS3ToLambda 265 | Properties: 266 | BucketName: !Ref ConfigurationBucketName 267 | AccessControl: BucketOwnerFullControl 268 | VersioningConfiguration: 269 | Status: Enabled 270 | BucketEncryption: 271 | ServerSideEncryptionConfiguration: 272 | - ServerSideEncryptionByDefault: 273 | KMSMasterKeyID: !Ref KMSKey 274 | SSEAlgorithm: aws:kms 275 | NotificationConfiguration: 276 | LambdaConfigurations: 277 | - Event: s3:ObjectCreated:* 278 | Filter: 279 | S3Key: 280 | Rules: 281 | - Name: suffix 282 | Value: !Ref UpdateFileName 283 | Function: !GetAtt AccountFactoryFunction.Arn 284 | 285 | AccountFactoryStateMachine: 286 | Type: 'AWS::StepFunctions::StateMachine' 287 | Properties: 288 | StateMachineName: !Ref StateMachineName 289 | RoleArn: !GetAtt AccountFactoryStateMachineRole.Arn 290 | DefinitionString: 291 | Fn::Sub: |- 292 | { 293 | "Comment": "A state machine that deployes products to new AWS Control Tower account", 294 | "StartAt": "Init-Baseline-Products", 295 | "States": { 296 | "Init-Baseline-Products": { 297 | "Type": "Task", 298 | "Resource": "${AccountFactoryFunction.Arn}", 299 | "TimeoutSeconds": 300, 300 | "HeartbeatSeconds": 60, 301 | "ResultPath": "$", 302 | "Next": "Wait-On-Init-Products" 303 | }, 304 | "Wait-On-Init-Products": { 305 | "Type": "Wait", 306 | "Seconds": 60, 307 | "Next": "Baseline-Products" 308 | }, 309 | "Baseline-Products": { 310 | "Type": "Task", 311 | "Resource": "${AccountFactoryFunction.Arn}", 312 | "TimeoutSeconds": 300, 313 | "HeartbeatSeconds": 60, 314 | "ResultPath": "$", 315 | "Next": "Deployment-Status" 316 | }, 317 | "Wait-For-Products": { 318 | "Type": "Wait", 319 | "Seconds": 60, 320 | "Next": "Baseline-Products" 321 | }, 322 | "Deployment-Status": { 323 | "Type": "Choice", 324 | "Choices": [ 325 | { 326 | "Variable": "$.status", 327 | "StringEquals": "progress", 328 | "Next": "Wait-For-Products" 329 | }, 330 | { 331 | "Variable": "$.status", 332 | "StringEquals": "done", 333 | "Next": "Success" 334 | } 335 | ] 336 | }, 337 | "Success": { 338 | "Type": "Succeed" 339 | } 340 | } 341 | } 342 | 343 | ControlTowerEvent: 344 | Type: AWS::Events::Rule 345 | Properties: 346 | Description: Fire lambda on AWS ontrol Tower new account creation 347 | EventPattern: 348 | source: 349 | - aws.controltower 350 | detail-type: 351 | - AWS Service Event via CloudTrail 352 | detail: 353 | serviceEventDetails: 354 | createManagedAccountStatus: 355 | state: 356 | - SUCCEEDED 357 | eventName: 358 | - CreateManagedAccount 359 | Name: 'AWS-Controle-Tower-Baseline-New-Account' 360 | Targets: 361 | - Id: 'AWS-Controle-Tower-Baseline-Lambda' 362 | Arn: !GetAtt AccountFactoryFunction.Arn 363 | 364 | ControlTowerEventPermission: 365 | Type: AWS::Lambda::Permission 366 | Properties: 367 | FunctionName: !Ref FunctionName 368 | Action: "lambda:InvokeFunction" 369 | Principal: "events.amazonaws.com" 370 | SourceArn: !GetAtt ControlTowerEvent.Arn 371 | SourceAccount: !Ref "AWS::AccountId" 372 | 373 | KMSKey: 374 | Type: AWS::KMS::Key 375 | Properties: 376 | Description: Encryption Key for SNS 377 | Enabled: 'true' 378 | EnableKeyRotation: 'false' 379 | KeyPolicy: 380 | Version: 2012-10-17 381 | Id: key-default-1 382 | Statement: 383 | - Sid: Root Access 384 | Effect: Allow 385 | Principal: 386 | AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root' 387 | Action: 'kms:*' 388 | Resource: '*' 389 | 390 | - Sid: User Access 391 | Effect: Allow 392 | Principal: 393 | AWS: 394 | - !GetAtt AccountFactoryLambdaRole.Arn 395 | Action: 396 | - kms:Encrypt 397 | - kms:Decrypt 398 | - kms:ReEncrypt* 399 | - kms:GenerateDataKey* 400 | - kms:DescribeKey 401 | - kms:CreateGrant 402 | - kms:RevokeGrant 403 | - kms:List* 404 | - kms:Get* 405 | - kms:RetireGrant 406 | Resource: '*' 407 | 408 | SNSTopic: 409 | Type: AWS::SNS::Topic 410 | Condition: CreateNotification 411 | Properties: 412 | Subscription: 413 | - Endpoint: !Ref NotificationEmail 414 | Protocol: "email" 415 | TopicName: !Ref TopicName 416 | KmsMasterKeyId: !Ref KMSKey -------------------------------------------------------------------------------- /src/common.py: -------------------------------------------------------------------------------- 1 | """Customizing the AWS Control Tower account factory with AWS Lambda and AWS Service Catalog""" 2 | 3 | """ 4 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | * SPDX-License-Identifier: MIT-0 6 | * 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 8 | * software and associated documentation files (the "Software"), to deal in the Software 9 | * without restriction, including without limitation the rights to use, copy, modify, 10 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 11 | * permit persons to whom the Software is furnished to do so. 12 | * 13 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | """ 20 | 21 | import json 22 | from datetime import datetime 23 | import time 24 | import os 25 | import logging 26 | import yaml 27 | import boto3 28 | from botocore.exceptions import ClientError 29 | from boto3.dynamodb.conditions import Attr 30 | 31 | LOGGER = logging.getLogger() 32 | if 'log_level' in os.environ: 33 | LOGGER.setLevel(os.environ['log_level']) 34 | else: 35 | LOGGER.setLevel(logging.INFO) 36 | 37 | logging.getLogger('boto3').setLevel(logging.CRITICAL) 38 | logging.getLogger('botocore').setLevel(logging.CRITICAL) 39 | 40 | class BaselineFunction(): 41 | """Baseline AWS Control Tower Account""" 42 | 43 | def __init__(self, region, master_account_id): 44 | 45 | self.config_bucket = os.environ['configuration_bucket_name'] 46 | self.lambda_role = os.environ['lambda_role'] 47 | self.state_machine_name = os.environ['state_machine_name'] 48 | self.master_account = master_account_id 49 | self.sns_topic_arn = None 50 | self.region = region 51 | notification_topic = os.environ['notification_topic'] 52 | if notification_topic != 'none': 53 | self.sns_topic_arn = f'arn:aws:sns:{region}:{master_account_id}:{notification_topic}' 54 | 55 | self.s3_client = boto3.client('s3') 56 | self.sc_client = boto3.client('servicecatalog', region_name=region) 57 | self.cfn_client = boto3.client('cloudformation', region_name=region) 58 | 59 | self.lambda_portfolio_list = [] 60 | 61 | def __get_s3(self, object_name): 62 | """init boto3 Amazon S3 client""" 63 | 64 | config_content = None 65 | 66 | try: 67 | s3_response = self.s3_client.get_object( 68 | Bucket=self.config_bucket, 69 | Key=object_name 70 | ) 71 | 72 | config_content = s3_response['Body'].read() 73 | except ClientError as error: 74 | self._log_error(f'Error load configuration: {error.response["Error"]}') 75 | self._send_notification('Error load configuration', f'Error: {error.response["Error"]}') 76 | 77 | return config_content 78 | 79 | def _load_config(self, config_file_name): 80 | """ 81 | get configuration from file from Amazon S3 Bucket. 82 | Bucket name define under 'configuration_bucket_name' os veriable 83 | If file has prefix (folder(s)) it has to be define under 'configuration_prefix' os veriable 84 | """ 85 | 86 | # pul config from S3 87 | config_content = self.__get_s3(config_file_name) 88 | if config_content: 89 | self.configuration = yaml.safe_load(config_content) 90 | return True 91 | else: 92 | return False 93 | 94 | def _set_table_connection(self): 95 | """set boto3 connection to Amazon DynamoDB table""" 96 | 97 | track_table_name = os.environ['track_table'] 98 | dynamodb = boto3.resource('dynamodb', region_name=self.region) 99 | self.track_table = dynamodb.Table(track_table_name) 100 | 101 | def _track_deployment(self, product, action, status): 102 | """add record to tracking table""" 103 | 104 | provision_name = product["provision_name"] 105 | product_name = product["product_name"] 106 | product_version = product["version"] 107 | account_id = self.destination_account 108 | regions = product['regions'] 109 | date = datetime.utcnow().strftime("%Y%m%d%H%M") 110 | 111 | # add deployment track to table 112 | try: 113 | self.track_table.put_item( 114 | Item={ 115 | 'ProvisionName': provision_name, 116 | 'Date': date, 117 | 'Product': product_name, 118 | 'Version': product_version, 119 | 'Account': account_id, 120 | 'Regions': regions, 121 | 'Action': action, 122 | 'DeploymentStatus': status 123 | } 124 | ) 125 | except Exception as error: 126 | LOGGER.error(f'Error insert deployment record. Error: {str(error)}') 127 | 128 | def _update_deployment_status(self, product, status): 129 | """update status in tracking table""" 130 | 131 | provision_name = product["provision_name"] 132 | 133 | # update deployment status 134 | try: 135 | track_product = self.track_table.scan( 136 | FilterExpression=Attr('ProvisionName').eq(provision_name) & Attr('DeploymentStatus').eq('in-progress') 137 | ) 138 | 139 | if track_product['Items']: 140 | track_date = track_product['Items'][0]['Date'] 141 | 142 | self.track_table.update_item( 143 | Key={ 144 | 'ProvisionName': provision_name, 145 | 'Date': track_date, 146 | }, 147 | UpdateExpression='SET DeploymentStatus = :val1', 148 | ExpressionAttributeValues={ 149 | ':val1': status 150 | } 151 | ) 152 | 153 | except Exception as error: 154 | LOGGER.error(f'Error update deployment status. Error: {str(error)}') 155 | 156 | def _send_notification(self, subject, message): 157 | """send SNS notification""" 158 | 159 | if self.sns_topic_arn: 160 | try: 161 | sns_client = boto3.client('sns') 162 | sns_client.publish( 163 | TopicArn=self.sns_topic_arn, 164 | Message=message, 165 | Subject=subject 166 | ) 167 | except ClientError as error: 168 | LOGGER.error(f'Error send notification. Subject {subject}. Message: {message}, Error: {error.response["Error"]}') 169 | 170 | def _get_product_id(self, product_name, product_version, portfolio_name): 171 | """Return product id , artifact id and path id for porvided product and porfolio name""" 172 | 173 | product_id = None 174 | artifact_id = None 175 | path_id = None 176 | version = None 177 | 178 | # add lamda role to portfolio 179 | if not self.__add_lambda_to_portfolio_principal(portfolio_name): 180 | return product_id, artifact_id, path_id 181 | 182 | self._log_info(f'Searching product name: {product_name}') 183 | 184 | try: 185 | 186 | sc_response = self.sc_client.search_products_as_admin( 187 | SortBy='Title', 188 | SortOrder='ASCENDING', 189 | ProductSource='ACCOUNT', 190 | Filters={ 191 | 'FullTextSearch': [ 192 | product_name 193 | ] 194 | } 195 | ) 196 | 197 | product_list = [] 198 | #Iterate through all return products 199 | for product in sc_response['ProductViewDetails']: 200 | # find product 201 | if product_name in product['ProductViewSummary']['Name']: 202 | product_list.append({'ProductId': product['ProductViewSummary']['ProductId'], 'CreatedTime': product['CreatedTime']}) 203 | 204 | # if product found 205 | if product_list: 206 | # if more the on product get the latest one based on the date/time 207 | last_product_id = max(product_list, key=lambda item: item['CreatedTime']) 208 | product_id = last_product_id['ProductId'] 209 | 210 | # search artifacts (versions) of product 211 | product_artifacts = self.sc_client.list_provisioning_artifacts( 212 | AcceptLanguage='en', 213 | ProductId=product_id 214 | ) 215 | 216 | if product_version: 217 | version = product_version 218 | for artifact in product_artifacts['ProvisioningArtifactDetails']: 219 | # find product that match provided version 220 | if artifact['Name'] == product_version: 221 | artifact_id = artifact['Id'] 222 | else: 223 | # get last product version by creation date 224 | last_artifact_id = max(product_artifacts['ProvisioningArtifactDetails'], key=lambda item: item['CreatedTime']) 225 | artifact_id = last_artifact_id['Id'] 226 | version = last_artifact_id['Name'] 227 | 228 | # get product launch path id 229 | launch_paths_list = self.sc_client.list_launch_paths( 230 | ProductId=product_id 231 | ) 232 | for launch_path in launch_paths_list['LaunchPathSummaries']: 233 | if launch_path['Name'] == portfolio_name: 234 | path_id = launch_path['Id'] 235 | 236 | except ClientError as error: 237 | self._log_error(f'Error obtain id for product: {product_name}. Error: {error.response["Error"]}') 238 | self._send_notification('Error obtain product id', f'Product name: {product_name}. Error: {error.response["Error"]}') 239 | 240 | return product_id, artifact_id, path_id, version 241 | 242 | def __add_lambda_to_portfolio_principal(self, portfolio_name): 243 | """Add AWS Lambda IAM role to portfolio in order for Lambda function to provision products""" 244 | 245 | # check if lambda already added to portfolio 246 | if portfolio_name in self.lambda_portfolio_list: 247 | return True 248 | 249 | portfolio_id = None 250 | has_lambda_principal = False 251 | 252 | try: 253 | portfolio_list = self.sc_client.list_portfolios() 254 | 255 | # get id for provided portfolio name 256 | for porfolio in portfolio_list['PortfolioDetails']: 257 | if porfolio['DisplayName'] == portfolio_name: 258 | portfolio_id = porfolio['Id'] 259 | 260 | # if portfolio exists and Lambda IAM role does not have access to porfolio, add it 261 | if portfolio_id: 262 | portfolio_principals = self.sc_client.list_principals_for_portfolio( 263 | PortfolioId=portfolio_id 264 | ) 265 | for principal in portfolio_principals['Principals']: 266 | if principal['PrincipalARN'] == self.lambda_role: 267 | has_lambda_principal = True 268 | self.lambda_portfolio_list.append(portfolio_name) 269 | break 270 | 271 | if not has_lambda_principal: 272 | self.sc_client.associate_principal_with_portfolio( 273 | PortfolioId=portfolio_id, 274 | PrincipalARN=self.lambda_role, 275 | PrincipalType='IAM' 276 | ) 277 | self.lambda_portfolio_list.append(portfolio_name) 278 | time.sleep(30) 279 | self._log_info(f'Lambda role added to portfolio {portfolio_name}') 280 | 281 | return True 282 | else: 283 | return False 284 | except ClientError as error: 285 | self._log_error(f'Error adding lambda role to portfolio : {portfolio_name}. Error: {error.response["Error"]}') 286 | self._send_notification('Error adding lambda role to portfolio', f'Portfolio name: {portfolio_name}. Error: {error.response["Error"]}') 287 | return False 288 | 289 | def __get_product_portfolio(self, product_id, portfolio_name): 290 | """Return portfolio id for porvided product id and porfolio name""" 291 | 292 | portfolio_id = None 293 | 294 | try: 295 | 296 | portfolio_list = self.sc_client.list_portfolios_for_product( 297 | ProductId=product_id 298 | ) 299 | 300 | for portfolio in portfolio_list['PortfolioDetails']: 301 | if portfolio['DisplayName'] == portfolio_name: 302 | portfolio_id = portfolio['Id'] 303 | break 304 | 305 | except ClientError as error: 306 | self._log_error(f'Error getting portfolio id for portfolio name: {portfolio_name} and product: {product_id}. Error: {error.response["Error"]}') 307 | self._send_notification('Error obtain portfolio id', f'Portfolio name: {portfolio_name}, product id: {product_id}. Error: {error.response["Error"]}') 308 | 309 | return portfolio_id 310 | 311 | def _update_product_constraint(self, product_id, porfolio_name, regions, destination_account): 312 | """Delete Launch contraint, if exists. Add/update StackSet contraint for product""" 313 | 314 | # get porfolio id 315 | porfolio_id = self.__get_product_portfolio(product_id, porfolio_name) 316 | 317 | if not porfolio_id: 318 | self._log_error(f'Portfolio id for product {product_id} could not be determined. Product deployment skipped') 319 | self._send_notification('Portfolio issue', f'Portfolio id for product {product_id} could not be determined. Product deployment skipped') 320 | return False 321 | 322 | try: 323 | # get list of constraints associate with product 324 | constraint_list = self.sc_client.list_constraints_for_portfolio( 325 | PortfolioId=porfolio_id, 326 | ProductId=product_id 327 | ) 328 | 329 | stack_set_constraint_id = None 330 | 331 | # iterate through constraints 332 | for constraint in constraint_list['ConstraintDetails']: 333 | # if product has Launch constraint, it has to be deleted as 334 | # Service Catalog does not allow both Launch and StackSet contraints 335 | # attach to product 336 | if constraint['Type'] == 'LAUNCH': 337 | self._log_info(f'Deleting LAUNCH contraint id: {constraint["ConstraintId"]}') 338 | self.sc_client.delete_constraint(Id=constraint['ConstraintId']) 339 | elif constraint['Type'] == 'STACKSET': 340 | stack_set_constraint_id = constraint['ConstraintId'] 341 | # if no StackSet constraint create one 342 | if not stack_set_constraint_id: 343 | admin_role = f'arn:aws:iam::{self.master_account}:role/service-role/AWSControlTowerStackSetRole' 344 | constraint_config = {"Version": "2.0", "Properties": {"AccountList": [destination_account], "RegionList": regions, "AdminRole": admin_role, "ExecutionRole": "AWSControlTowerExecution"}} 345 | 346 | self.sc_client.create_constraint( 347 | PortfolioId=porfolio_id, 348 | ProductId=product_id, 349 | Parameters=json.dumps(constraint_config), 350 | Type='STACKSET', 351 | Description='Control-Tower-Account-Baseline', 352 | IdempotencyToken=f'ct-baseline-constraint-{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}' 353 | ) 354 | # if product has StackSet constraint add the new account and region, if needed 355 | else: 356 | constraint_info = self.sc_client.describe_constraint( 357 | Id=stack_set_constraint_id 358 | ) 359 | 360 | constraint_config = json.loads(constraint_info['ConstraintParameters']) 361 | 362 | if destination_account not in constraint_config['Properties']['AccountList']: 363 | (constraint_config['Properties']['AccountList']).append(destination_account) 364 | 365 | for region in regions: 366 | if region not in constraint_config['Properties']['RegionList']: 367 | (constraint_config['Properties']['RegionList']).append(region) 368 | 369 | self.sc_client.update_constraint( 370 | Id=stack_set_constraint_id, 371 | Parameters=json.dumps(constraint_config) 372 | ) 373 | 374 | return True 375 | except ClientError as error: 376 | self._log_error(f'Error adding account to contraint: Error: {error.response["Error"]}') 377 | self._send_notification('Error adding account to contraint', f'Error: {error.response["Error"]}') 378 | return False 379 | 380 | def _start_provision_product(self, provision_product, update_product, destination_account): 381 | """Call AWS Step Function State Machine""" 382 | 383 | if not provision_product and not update_product: 384 | self._log_info('Nothing to process') 385 | return None 386 | 387 | # create execute name 388 | sfn_execution_name = f'control-tower-account-factory-execution-{destination_account}-{datetime.utcnow().strftime("%Y%m%d%H%M%S%F")}' 389 | self._log_info('Starting AWS Step Function for Products') 390 | 391 | max_iterations = (self.configuration['max_iterations'] if 'max_iterations' in self.configuration else 0) 392 | # format input 393 | sfn_input = json.dumps({"provision_products": provision_product, "update_products": update_product, "account": destination_account, "status": "init", "deployed_products":[], "failed_products": [], "skipped_products": [], "max_iterations": max_iterations, "iteration": 0}) 394 | self._log_info(f'SFN inout: {sfn_input}') 395 | 396 | try: 397 | sfn_client = boto3.client('stepfunctions') 398 | 399 | # call SFN 400 | sfn_client.start_execution( 401 | stateMachineArn=f'arn:aws:states:{self.region}:{self.master_account}:stateMachine:{self.state_machine_name}', 402 | name=sfn_execution_name, 403 | input=sfn_input 404 | ) 405 | except ClientError as error: 406 | self._log_error(f'Error start step function. Execution name: {sfn_execution_name}. Error: {error.response["Error"]}') 407 | self._send_notification('Error start step function', f'Execution name: {sfn_execution_name}. Error: {error.response["Error"]}') 408 | 409 | def _log_error(self, message): 410 | LOGGER.error(message) 411 | 412 | def _log_info(self, message): 413 | LOGGER.info(message) 414 | -------------------------------------------------------------------------------- /src/handler.py: -------------------------------------------------------------------------------- 1 | """Customizing the AWS Control Tower account factory with AWS Lambda and AWS Service Catalog""" 2 | 3 | """ 4 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | * SPDX-License-Identifier: MIT-0 6 | * 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 8 | * software and associated documentation files (the "Software"), to deal in the Software 9 | * without restriction, including without limitation the rights to use, copy, modify, 10 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 11 | * permit persons to whom the Software is furnished to do so. 12 | * 13 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | """ 20 | 21 | import json 22 | import copy 23 | import os 24 | import logging 25 | import boto3 26 | from botocore.exceptions import ClientError 27 | from common import BaselineFunction 28 | 29 | LOGGER = logging.getLogger() 30 | if 'log_level' in os.environ: 31 | LOGGER.setLevel(os.environ['log_level']) 32 | else: 33 | LOGGER.setLevel(logging.INFO) 34 | 35 | logging.getLogger('boto3').setLevel(logging.CRITICAL) 36 | logging.getLogger('botocore').setLevel(logging.CRITICAL) 37 | 38 | SESSION = boto3.session.Session() 39 | REGION = SESSION.region_name 40 | 41 | class BaselineInit(BaselineFunction): 42 | """Baseline AWS Control Tower Account""" 43 | 44 | def __init__(self, destimation_account_id, ou_name, region, master_account_id): 45 | self.account_id = destimation_account_id 46 | self.ou_name = ou_name 47 | self.config_file = os.environ['configuration_file'] 48 | 49 | # init common functions class 50 | super().__init__(region, master_account_id) 51 | 52 | # if configuration loaded successfuly, start deployment into new account 53 | if self._load_config(self.config_file): 54 | self.__init_deployments() 55 | 56 | def __init_deployments(self): 57 | """initialize deployment""" 58 | provision_product = [] 59 | # process defult products common to all ou 60 | provision_product = self.__get_products_to_provision('default', provision_product) 61 | # process prducts specific for the OU where the new account was added 62 | provision_product = self.__get_products_to_provision(self.ou_name, provision_product) 63 | # start provisioning process 64 | self._start_provision_product(provision_product, [], self.account_id) 65 | 66 | def __get_products_to_provision(self, organization_unit, provision_product): 67 | """process products that need to be provision in new account""" 68 | self._log_info(f'Validating products for ou: {organization_unit}') 69 | if (organization_unit in self.configuration['organization_units'] and 'products' in self.configuration['organization_units'][organization_unit]): 70 | for product in self.configuration['organization_units'][organization_unit]['products']: 71 | if 'product_name' in product and 'provision_name' in product and 'portfolio_name' in product and 'regions' in product: 72 | # check if product version provided in configuration 73 | product_version = product['product_version'] if 'product_version' in product else None 74 | # obtain product id and artifact id 75 | product_id, artifact_id, path_id, version = self._get_product_id(product['product_name'], product_version, product['portfolio_name']) 76 | if product_id and artifact_id and path_id: 77 | if self._update_product_constraint(product_id, product['portfolio_name'], product['regions'], self.account_id): 78 | product['id'] = product_id 79 | product['artifact'] = artifact_id 80 | product['version'] = version 81 | product['path'] = path_id 82 | product['provision_name'] = f'{self.account_id }-{product["provision_name"]}' 83 | provision_product.append(product) 84 | return provision_product 85 | 86 | ############################################################################################################################################### 87 | 88 | class BaselineAccount(BaselineFunction): 89 | """Baseline AWS Control Tower Account""" 90 | 91 | def __init__(self, account_id, provision_products, update_products, deployed_products, failed_products, skipped_products, execution_account_id, iteration, max_iterations, region): 92 | self.destination_account = account_id 93 | self.execution_account = execution_account_id 94 | 95 | # init common functions class 96 | super().__init__(region, execution_account_id) 97 | 98 | self.response = {"provision_products": provision_products, "update_products": update_products, "account": account_id, "status": "progress", "deployed_products":deployed_products, "failed_products": failed_products, "skipped_products": skipped_products, "max_iterations": max_iterations, "iteration": iteration} 99 | 100 | # to avoid infinite loop, lambda will stop execution of state machine after define maximum interations 101 | if iteration >= max_iterations: 102 | self._log_error(f'Baseline of new account reach max iterations ({max_iterations})') 103 | self._send_notification('Baseline time out', f'Baseline of new account reach max iterations ({max_iterations})') 104 | self.response['status'] = 'done' 105 | else: 106 | # get list of provision name from products define in configuration 107 | self.iterate = False 108 | self.deployment_products_list = [] 109 | self.__get_products_in_scope(provision_products) 110 | self.__get_products_in_scope(update_products) 111 | 112 | # set connection to tracking table 113 | self._set_table_connection() 114 | 115 | # start deployment process 116 | provision_status = self.__baseline_products(provision_products, 'deploy') 117 | # start update process 118 | update_status = self.__baseline_products(update_products, 'update') 119 | 120 | if provision_status == 'done' and update_status == 'done': 121 | self.response['status'] = 'done' 122 | else: 123 | self.response['status'] = 'progress' 124 | # increase iteration if dependency still in progress 125 | if self.iterate: 126 | self.response['iteration'] = iteration + 1 127 | 128 | def __baseline_products(self, products, action): 129 | """ 130 | go through product configuration 131 | check deployment dependencies 132 | start deployment 133 | """ 134 | status = 'done' 135 | 136 | 137 | for product in products: 138 | # check if product was deployed or failed/skipped deployment 139 | if product['provision_name'] in self.response['deployed_products'] or product['provision_name'] in self.response['failed_products'] or product['provision_name'] in self.response['skipped_products']: 140 | if product['provision_name'] in self.response['deployed_products']: 141 | # check deployemnt status 142 | provision_status = self.__get_provision_status(product['provision_name']) 143 | self._log_info(f'Deployemnt Status - Product: {product["provision_name"]} Status: {provision_status}') 144 | if provision_status in ['error', 'failed']: 145 | self.response['failed_products'].append(product['provision_name']) 146 | self.response['deployed_products'].remove(product['provision_name']) 147 | self._update_deployment_status(product, 'failed') 148 | elif provision_status != 'done': 149 | status = 'progress' 150 | else: 151 | self._update_deployment_status(product, 'done') 152 | continue 153 | 154 | # if no dependencies, deploy product 155 | if 'dependson' not in product: 156 | if self.__process_product(product, action): 157 | self.response['deployed_products'].append(product['provision_name']) 158 | status = 'progress' 159 | else: 160 | self.response['failed_products'].append(product['provision_name']) 161 | status = 'failed' 162 | else: 163 | depend_status = 'done' 164 | for depend_product in product['dependson']: 165 | depend_product = f'{self.destination_account}-{depend_product}' 166 | self._log_info(f'Checking dependency {depend_product} for product {product["provision_name"]}') 167 | if depend_product in self.response['failed_products'] or depend_product in self.response['skipped_products']: 168 | self._log_info(f'Status dependency {depend_product} for product {product["provision_name"]}: failed') 169 | self.response['skipped_products'].append(product['provision_name']) 170 | depend_status = 'failed' 171 | break 172 | # if dependency on the list but did not start deployment, wait 173 | elif depend_product in self.deployment_products_list and depend_product not in self.response['deployed_products']: 174 | self._log_info(f'Status dependency {depend_product} for product {product["provision_name"]}: not started') 175 | depend_status = 'progress' 176 | self.iterate = True 177 | # otherwise check dependency deployment status 178 | else: 179 | provision_status = self.__get_provision_status(depend_product) 180 | self._log_info(f'Status dependency {depend_product} for product {product["provision_name"]}: {provision_status}') 181 | # if any dependencies failed to deploy, skip product deployment 182 | if provision_status in ['error', 'failed']: 183 | self.response['failed_products'].append(depend_product) 184 | self.response['skipped_products'].append(product['provision_name']) 185 | depend_status = 'failed' 186 | break 187 | elif provision_status != 'done': 188 | depend_status = 'progress' 189 | 190 | # if all dependencies deployed successfuly, start product deployment 191 | if depend_status == 'done': 192 | if self.__process_product(product, action): 193 | self.response['deployed_products'].append(product['provision_name']) 194 | status = 'progress' 195 | else: 196 | self.response['failed_products'].append(product['provision_name']) 197 | 198 | return status 199 | 200 | def __get_products_in_scope(self, products): 201 | """ 202 | get list of the provision names that are in the scope of deployment/update 203 | list will be used by dependency validation to identify if depended product 204 | need to be updated or not 205 | """ 206 | 207 | for product in products: 208 | self.deployment_products_list.append(product['provision_name']) 209 | 210 | 211 | def __process_product(self, product, action): 212 | """deploy or update product""" 213 | 214 | status = False 215 | 216 | # deploy product 217 | if action == 'deploy': 218 | status = self.__provision_product(product) 219 | 220 | # update product 221 | if action == 'update': 222 | status = self.__update_product(product) 223 | 224 | return status 225 | 226 | def __provision_product(self, product): 227 | """provision product to new account""" 228 | 229 | self._log_info(f'Provisioning product {product["product_name"]} - Provision Name: {product["provision_name"]}') 230 | try: 231 | if 'parameters' in product: 232 | parameters = self.__process_paramters(product['parameters']) 233 | self.sc_client.provision_product( 234 | ProductId=product['id'], 235 | ProvisioningArtifactId=product['artifact'], 236 | ProvisionedProductName=product["provision_name"], 237 | ProvisioningParameters=parameters, 238 | ProvisioningPreferences={ 239 | 'StackSetAccounts': [ 240 | self.destination_account 241 | ], 242 | 'StackSetRegions': product['regions'] 243 | } 244 | ) 245 | else: 246 | self.sc_client.provision_product( 247 | ProductId=product['id'], 248 | ProvisioningArtifactId=product['artifact'], 249 | ProvisionedProductName=product["provision_name"], 250 | ProvisioningPreferences={ 251 | 'StackSetAccounts': [ 252 | self.destination_account 253 | ], 254 | 'StackSetRegions': product['regions'] 255 | } 256 | ) 257 | self._log_info(f'Provision {product["provision_name"]} started') 258 | self._track_deployment(product, 'deployment', 'in-progress') 259 | return True 260 | except ClientError as error: 261 | self._log_error(f'Error provision product {product["product_name"]}: {error.response["Error"]}') 262 | self._send_notification('Error provision product', f'Error provision product {product["product_name"]}: {error.response["Error"]}') 263 | self._track_deployment(product, 'deployment', 'failed') 264 | return False 265 | 266 | def __update_product(self, product): 267 | """update product""" 268 | 269 | self._log_info(f'Update product {product["product_name"]} - Provision Name: {product["provision_name"]}') 270 | try: 271 | if 'parameters' in product: 272 | parameters = self.__process_paramters(product['parameters']) 273 | 274 | self.sc_client.update_provisioned_product( 275 | ProvisionedProductId=product['provision_id'], 276 | ProductId=product['id'], 277 | ProvisioningArtifactId=product['artifact'], 278 | ProvisioningParameters=parameters, 279 | ProvisioningPreferences={ 280 | 'StackSetAccounts': [ 281 | self.destination_account 282 | ], 283 | 'StackSetRegions': product['regions'], 284 | 'StackSetOperationType': 'UPDATE' 285 | } 286 | ) 287 | else: 288 | self.sc_client.update_provisioned_product( 289 | ProvisionedProductId=product['provision_id'], 290 | ProductId=product['id'], 291 | ProvisioningArtifactId=product['artifact'], 292 | ProvisioningPreferences={ 293 | 'StackSetAccounts': [ 294 | self.destination_account 295 | ], 296 | 'StackSetRegions': product['regions'], 297 | 'StackSetOperationType': 'UPDATE' 298 | } 299 | ) 300 | self._log_info(f'Update {product["provision_name"]} started') 301 | self._track_deployment(product, 'update', 'in-progress') 302 | return True 303 | except ClientError as error: 304 | self._log_error(f'Error update product {product["product_name"]}: {error.response["Error"]}') 305 | self._send_notification('Error update product', f'Error provision product {product["product_name"]}: {error.response["Error"]}') 306 | self._track_deployment(product, 'update', 'failed') 307 | return False 308 | 309 | def __process_paramters(self, parameters): 310 | """replace pseudo parameters""" 311 | 312 | parameter_list = [] 313 | 314 | for parameter in parameters: 315 | if 'Key' not in parameter or 'Value' not in parameter: 316 | continue 317 | elif '{accountid}' in parameter['Value']: 318 | parameter['Value'] = parameter['Value'].replace('{accountid}', self.destination_account) 319 | elif '{masteraccountid}' in parameter['Value']: 320 | parameter['Value'] = parameter['Value'].replace('{masteraccountid}', self.execution_account) 321 | 322 | parameter_list.append(parameter) 323 | 324 | return parameter_list 325 | 326 | 327 | def __get_provision_status(self, provision_name): 328 | """return status of provisioning product""" 329 | 330 | status = '' 331 | 332 | search_query = f'name:{provision_name}' 333 | 334 | try: 335 | provision_info = self.sc_client.search_provisioned_products( 336 | AccessLevelFilter={ 337 | 'Key': 'Account', 338 | 'Value': 'self' 339 | }, 340 | Filters={ 341 | 'SearchQuery': [ 342 | search_query 343 | ] 344 | } 345 | ) 346 | 347 | for provision in provision_info['ProvisionedProducts']: 348 | if provision['Status'] == 'AVAILABLE': 349 | status = 'done' 350 | elif provision['Status'] in ['ERROR', 'TAINTED']: 351 | status = 'failed' 352 | else: 353 | status = 'progress' 354 | 355 | except ClientError as error: 356 | self._log_error(f'Error obtain provision status for {provision_name}. Error: {error.response["Error"]}') 357 | self._send_notification('Error obtain provision status', f'Error obtain provision status for {provision_name}. Error: {error.response["Error"]}') 358 | status = 'error' 359 | 360 | return status 361 | 362 | 363 | def get_response(self): 364 | """return response back to state machine""" 365 | 366 | if self.response["status"] == 'done': 367 | # send final status 368 | deployed_products = '\n'.join(self.response["deployed_products"]) 369 | failed_products = '\n'.join(self.response["failed_products"]) 370 | skipped_products = '\n'.join(self.response["skipped_products"]) 371 | message = f'Baseline of account {self.response["account"]} completed. Status:\n\nDeployed products: \n{deployed_products} \n\nFailed products: \n{failed_products} \n\nSkipped products: \n{skipped_products}' 372 | self._send_notification('Baseline Completed', message) 373 | 374 | return self.response 375 | 376 | ############################################################################################################################################### 377 | 378 | class BaselineUpdate(BaselineFunction): 379 | """Update AWS Control Tower Account""" 380 | 381 | def __init__(self, region, master_account_id): 382 | 383 | self.state_machine_name = os.environ['state_machine_name'] 384 | self.lambda_role = os.environ['lambda_role'] 385 | self.update_file = os.environ['update_file'] 386 | 387 | # init common functions class 388 | super().__init__(region, master_account_id) 389 | 390 | self.org_client = boto3.client('organizations', region_name=region) 391 | 392 | # if configuration loaded successfuly, start deployment into new account 393 | if self._load_config(self.update_file): 394 | self.__init_update() 395 | 396 | def __init_update(self): 397 | """initialize update""" 398 | 399 | self.account_to_process = [] 400 | 401 | # process configuration file 402 | products_to_provision, products_to_update = self.__process_configuration() 403 | 404 | # iterate through all accounts that need to be updated 405 | for account in self.account_to_process: 406 | provision_product = (products_to_provision[account] if account in products_to_provision else []) 407 | update_product = (products_to_update[account] if account in products_to_update else []) 408 | # start provision/update products process 409 | self._start_provision_product(provision_product, update_product, account) 410 | 411 | 412 | def __process_configuration(self): 413 | """process products that need to be provision or update across accounts""" 414 | 415 | self._log_info('Start process update configuration') 416 | products_to_provision = {} 417 | products_to_update = {} 418 | 419 | for product in self.configuration['products']: 420 | if 'product_name' in product and 'portfolio_name' in product and 'provision_name' in product and 'regions' in product and ('accounts' in product or 'organization_units' in product): 421 | # check if product version provided in configuration 422 | product_version = product['product_version'] if 'product_version' in product else None 423 | # obtain product id and artifact id 424 | product_id, artifact_id, path_id, version = self._get_product_id(product['product_name'], product_version, product['portfolio_name']) 425 | if product_id and artifact_id and path_id: 426 | accounts = (product['accounts'] if 'accounts' in product else None) 427 | organization_unit = (product['organization_units'] if 'organization_units' in product else None) 428 | # get provisioned names for product 429 | provision_name_list = self.__get_product_provision_list(product_id) 430 | # get list of accounts where product need to be updated 431 | account_list = self.__get_account_list(accounts, organization_unit) 432 | for account in account_list: 433 | # update product stackset constrain 434 | if self._update_product_constraint(product_id, product['portfolio_name'], product['regions'], account): 435 | provision_name = f'{account}-{product["provision_name"]}' 436 | # make copy of product record 437 | deploy_product = copy.deepcopy(product) 438 | update_product = copy.deepcopy(product) 439 | # if account on the list - update account 440 | if provision_name in provision_name_list: 441 | stack_set_name = provision_name_list[provision_name]['stack_set_name'] 442 | regions_list = self.__get_product_deployment_regions(stack_set_name) 443 | 444 | update_regions = [] 445 | # check in which regions product was deployed 446 | for region in product['regions']: 447 | # initialize new deployment in missin regions 448 | if region not in regions_list: 449 | if 'deployifnotexist' in product and product['deployifnotexist'] == True: 450 | self._log_info(f'Creating new stack instance for: {product["product_name"]} in {account} {region}') 451 | self.__add_stack_instance(stack_set_name, account, region) 452 | else: 453 | self._log_info(f'Skipped deployment for: {product["product_name"]} in {account} {region}') 454 | else: 455 | update_regions.append(region) 456 | 457 | # for existing regions processd with updates 458 | if update_regions: 459 | update_product['id'] = product_id 460 | update_product['artifact'] = artifact_id 461 | update_product['path'] = path_id 462 | update_product['provision_id'] = provision_name_list[provision_name]['id'] 463 | update_product['regions'] = update_regions 464 | update_product['account'] = account 465 | update_product['version'] = version 466 | update_product['provision_name'] = provision_name 467 | 468 | if account in products_to_update: 469 | products_to_update[account].append(update_product) 470 | else: 471 | products_to_update[account] = [update_product] 472 | 473 | if account not in self.account_to_process: 474 | self.account_to_process.append(account) 475 | else: 476 | if 'deployifnotexist' in product and product['deployifnotexist'] == True: 477 | # if this is new account, new deployment will be created 478 | deploy_product['id'] = product_id 479 | deploy_product['artifact'] = artifact_id 480 | deploy_product['path'] = path_id 481 | deploy_product['account'] = account 482 | deploy_product['version'] = version 483 | deploy_product['provision_name'] = provision_name 484 | 485 | if account in products_to_provision: 486 | products_to_provision[account].append(deploy_product) 487 | else: 488 | products_to_provision[account] = [deploy_product] 489 | 490 | if account not in self.account_to_process: 491 | self.account_to_process.append(account) 492 | else: 493 | self._log_info(f'Skipped deployment for: {product["product_name"]} in {account}') 494 | 495 | 496 | return products_to_provision, products_to_update 497 | 498 | def __add_stack_instance(self, stack_set_name, account, region): 499 | """create new deployment stack instance """ 500 | 501 | try: 502 | self.cfn_client.create_stack_instances( 503 | StackSetName=stack_set_name, 504 | DeploymentTargets={ 505 | 'Accounts': [ 506 | account 507 | ] 508 | }, 509 | Regions=[ 510 | region 511 | ] 512 | ) 513 | return True 514 | except ClientError as error: 515 | self._log_error(f'Error create stack instance. Error: {error.response["Error"]}') 516 | self._send_notification('Error create stack instance', f'Error: {error.response["Error"]}') 517 | return False 518 | 519 | def __get_account_list(self, accounts=None, deployment_ou_list=None): 520 | """get list of the accounts to process""" 521 | 522 | self._log_info('Get accounts list') 523 | account_list = (accounts if accounts else []) 524 | 525 | if deployment_ou_list: 526 | try: 527 | response_roots = self.org_client.list_roots() 528 | root_ou_id = response_roots['Roots'][0]['Id'] 529 | ou_list = self.__get_ou_ids(root_ou_id) 530 | 531 | for organization_unit in ou_list: 532 | if organization_unit not in deployment_ou_list: 533 | continue 534 | org_id = ou_list[organization_unit] 535 | paginator = self.org_client.get_paginator('list_accounts_for_parent') 536 | org_iterator = paginator.paginate( 537 | ParentId=org_id 538 | ) 539 | for page in org_iterator: 540 | for account in page['Accounts']: 541 | if account['Id'] not in account_list: 542 | account_list.append(account['Id']) 543 | except ClientError as error: 544 | self._log_error(f'Error obtain account list. Error: {error.response["Error"]}') 545 | self._send_notification('Error obtain account list', f'Error: {error.response["Error"]}') 546 | 547 | return account_list 548 | 549 | def __get_ou_ids(self, parent_id): 550 | """get ids of organization unit""" 551 | 552 | self._log_info('Get organization unit ids') 553 | ou_list = {} 554 | try: 555 | paginator = self.org_client.get_paginator('list_organizational_units_for_parent') 556 | ou_iterator = paginator.paginate( 557 | ParentId=parent_id 558 | ) 559 | for page in ou_iterator: 560 | for organization_unit in page['OrganizationalUnits']: 561 | ou_list[organization_unit['Name']] = organization_unit['Id'] 562 | except ClientError as error: 563 | self._log_error(f'Error obtain organization unit ids. Error: {error.response["Error"]}') 564 | self._send_notification('Error obtain organization unit ids', f'Error: {error.response["Error"]}') 565 | 566 | return ou_list 567 | 568 | def __get_product_provision_list(self, product_id): 569 | """get products provision name list""" 570 | 571 | provision_name_list = {} 572 | 573 | try: 574 | provision_list = self.sc_client.search_provisioned_products( 575 | AccessLevelFilter={ 576 | 'Key': 'Account', 577 | 'Value': 'self' 578 | }, 579 | Filters={ 580 | 'SearchQuery': [ 581 | product_id 582 | ] 583 | } 584 | ) 585 | 586 | if 'ProvisionedProducts' in provision_list: 587 | for provision in provision_list['ProvisionedProducts']: 588 | if provision['Type'] == 'CFN_STACKSET' and 'PhysicalId' in provision: 589 | provision_id = provision['Id'] 590 | stack_set_name = ((provision['PhysicalId']).split('/')[1]).split(':')[0] 591 | provision_name_list[provision['Name']] = {"id": provision_id, "stack_set_name": stack_set_name} 592 | 593 | except ClientError as error: 594 | self._log_error(f'Error obtain provision list for product {product_id}. Error: {error.response["Error"]}') 595 | self._send_notification('Error obtain provision list for product', f'Product: {product_id} Error: {error.response["Error"]}') 596 | 597 | return provision_name_list 598 | 599 | def __get_product_deployment_regions(self, stack_set_name): 600 | """get regions where product was deployed""" 601 | 602 | regions_list = [] 603 | 604 | try: 605 | paginator = self.cfn_client.get_paginator('list_stack_instances') 606 | ss_iterator = paginator.paginate( 607 | StackSetName=stack_set_name 608 | ) 609 | 610 | for page in ss_iterator: 611 | for instance in page['Summaries']: 612 | if instance['Region'] not in regions_list: 613 | regions_list.append(instance['Region']) 614 | except ClientError as error: 615 | self._log_error(f'Error obtain list for regions for stack set {stack_set_name}. Error: {error.response["Error"]}') 616 | self._send_notification('Error obtain regions for stack set', f'Stack Set: {stack_set_name} Error: {error.response["Error"]}') 617 | 618 | return regions_list 619 | 620 | 621 | ############################################################################################################################################### 622 | 623 | def lambda_handler(event, context): 624 | """lambda entry""" 625 | LOGGER.info(f'REQUEST RECEIVED: {json.dumps(event, default=str)}') 626 | 627 | # get current account 628 | execution_account_id = context.invoked_function_arn.split(':')[4] 629 | 630 | # check if lambda call by AWS CloudWatch Event in response to creation of new AWS Control Tower account 631 | if ('detail' in event) and ('eventName' in event['detail']) and (event['detail']['eventName'] == 'CreateManagedAccount'): 632 | service_detail = event['detail']['serviceEventDetails'] 633 | status = service_detail['createManagedAccountStatus'] 634 | LOGGER.info( 635 | 'AWS Control Tower Event: CreateManagedAccount %s' % (status) 636 | ) 637 | # get new account id and name 638 | account_id = status['account']['accountId'] 639 | account_name = status['account']['accountName'] 640 | # get organization unit where the new account was added 641 | ou_name = status['organizationalUnit']['organizationalUnitName'] 642 | # if account creation completed, start baselien process 643 | if status['state'] == 'SUCCEEDED': 644 | LOGGER.info(f'Init Account Baseline. Account name: {account_name}, Account id: {account_id}, OU: {ou_name}') 645 | BaselineInit(account_id, ou_name, REGION, execution_account_id) 646 | else: 647 | LOGGER.info(f'Baseline skipped. Account status: {status["state"]}') 648 | elif 'Records' in event: 649 | update_file = os.environ['update_file'] 650 | for record in event['Records']: 651 | if 's3' in record and record['s3']['object']['key'] == update_file: 652 | LOGGER.info('Init Update Products') 653 | BaselineUpdate(REGION, execution_account_id) 654 | 655 | # check if AWS Lambda call by state machine 656 | elif ('provision_products' in event and 'account' in event): 657 | 658 | deployed_products = (event['deployed_products'] if 'deployed_products' in event else []) 659 | failed_products = (event['failed_products'] if 'failed_products' in event else []) 660 | skipped_products = (event['skipped_products'] if 'skipped_products' in event else []) 661 | max_iterations = (event['max_iterations'] if 'max_iterations' in event and int(event['max_iterations']) > 0 else int(os.environ['max_iterations'])) 662 | # increase how many time lambda was call be state machine 663 | iteration = (event['iteration'] if 'iteration' in event else 0) 664 | 665 | LOGGER.info(f'Init Product Baseline. Account id: {event["account"]}') 666 | # start/ contiune account baseline process 667 | baseline_account = BaselineAccount(event['account'], event['provision_products'], event['update_products'], deployed_products, failed_products, skipped_products, execution_account_id, iteration, max_iterations, REGION) 668 | # get baseline status 669 | stm_response = baseline_account.get_response() 670 | LOGGER.info(f'Response status: {stm_response["status"]}') 671 | # response status back to state machine 672 | return stm_response 673 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML -------------------------------------------------------------------------------- /update-template.yml: -------------------------------------------------------------------------------- 1 | products: 2 | - product_name: # name of the product 3 | product_version: <(optional) product version # optional value. If omitted the latest version of product will be deployed 4 | portfolio_name: # name of the portfolio 5 | provision_name: # name use by AWS Service Catalog to provision new product 6 | dependson: # optional: list of the provision names that must to be updated or deployed before product 7 | - 8 | parameters: # optional: list of parameters and values 9 | - Key: 10 | Value: 11 | regions: # list of AWS regions where to update or deploy product 12 | - 13 | accounts: # list of AWS account ids where update or deploy product 14 | - 15 | organization_units: # list of organization units where update or deploy product 16 | - 17 | deployifnotexist: true 18 | # optional: overwrite default value for maximum iteration 19 | max_iterations: 20 | 21 | --------------------------------------------------------------------------------