1#!/usr/local/bin/python3.8 2# Copyright: (c) 2018, Ansible Project 3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 4 5from __future__ import absolute_import, division, print_function 6__metaclass__ = type 7 8 9DOCUMENTATION = r''' 10--- 11module: cloudformation_stack_set 12version_added: 1.0.0 13short_description: Manage groups of CloudFormation stacks 14description: 15 - Launches/updates/deletes AWS CloudFormation Stack Sets. 16notes: 17 - To make an individual stack, you want the M(amazon.aws.cloudformation) module. 18options: 19 name: 20 description: 21 - Name of the CloudFormation stack set. 22 required: true 23 type: str 24 description: 25 description: 26 - A description of what this stack set creates. 27 type: str 28 parameters: 29 description: 30 - A list of hashes of all the template variables for the stack. The value can be a string or a dict. 31 - Dict can be used to set additional template parameter attributes like UsePreviousValue (see example). 32 default: {} 33 type: dict 34 state: 35 description: 36 - If I(state=present), stack will be created. If I(state=present) and if stack exists and template has changed, it will be updated. 37 If I(state=absent), stack will be removed. 38 default: present 39 choices: [ present, absent ] 40 type: str 41 template: 42 description: 43 - The local path of the CloudFormation template. 44 - This must be the full path to the file, relative to the working directory. If using roles this may look 45 like C(roles/cloudformation/files/cloudformation-example.json). 46 - If I(state=present) and the stack does not exist yet, either I(template), I(template_body) or I(template_url) 47 must be specified (but only one of them). 48 - If I(state=present), the stack does exist, and neither I(template), I(template_body) nor I(template_url) 49 are specified, the previous template will be reused. 50 type: path 51 template_body: 52 description: 53 - Template body. Use this to pass in the actual body of the CloudFormation template. 54 - If I(state=present) and the stack does not exist yet, either I(template), I(template_body) or I(template_url) 55 must be specified (but only one of them). 56 - If I(state=present), the stack does exist, and neither I(template), I(template_body) nor I(template_url) 57 are specified, the previous template will be reused. 58 type: str 59 template_url: 60 description: 61 - Location of file containing the template body. 62 - The URL must point to a template (max size 307,200 bytes) located in an S3 bucket in the same region 63 as the stack. 64 - If I(state=present) and the stack does not exist yet, either I(template), I(template_body) or I(template_url) 65 must be specified (but only one of them). 66 - If I(state=present), the stack does exist, and neither I(template), I(template_body) nor I(template_url) 67 are specified, the previous template will be reused. 68 type: str 69 purge_stacks: 70 description: 71 - Only applicable when I(state=absent). Sets whether, when deleting a stack set, the stack instances should also be deleted. 72 - By default, instances will be deleted. To keep stacks when stack set is deleted set I(purge_stacks=false). 73 type: bool 74 default: true 75 wait: 76 description: 77 - Whether or not to wait for stack operation to complete. This includes waiting for stack instances to reach UPDATE_COMPLETE status. 78 - If you choose not to wait, this module will not notify when stack operations fail because it will not wait for them to finish. 79 type: bool 80 default: false 81 wait_timeout: 82 description: 83 - How long to wait (in seconds) for stacks to complete create/update/delete operations. 84 default: 900 85 type: int 86 capabilities: 87 description: 88 - Capabilities allow stacks to create and modify IAM resources, which may include adding users or roles. 89 - Currently the only available values are 'CAPABILITY_IAM' and 'CAPABILITY_NAMED_IAM'. Either or both may be provided. 90 - > 91 The following resources require that one or both of these parameters is specified: AWS::IAM::AccessKey, 92 AWS::IAM::Group, AWS::IAM::InstanceProfile, AWS::IAM::Policy, AWS::IAM::Role, AWS::IAM::User, AWS::IAM::UserToGroupAddition 93 type: list 94 elements: str 95 choices: 96 - 'CAPABILITY_IAM' 97 - 'CAPABILITY_NAMED_IAM' 98 regions: 99 description: 100 - A list of AWS regions to create instances of a stack in. The I(region) parameter chooses where the Stack Set is created, and I(regions) 101 specifies the region for stack instances. 102 - At least one region must be specified to create a stack set. On updates, if fewer regions are specified only the specified regions will 103 have their stack instances updated. 104 type: list 105 elements: str 106 accounts: 107 description: 108 - A list of AWS accounts in which to create instance of CloudFormation stacks. 109 - At least one region must be specified to create a stack set. On updates, if fewer regions are specified only the specified regions will 110 have their stack instances updated. 111 type: list 112 elements: str 113 administration_role_arn: 114 description: 115 - ARN of the administration role, meaning the role that CloudFormation Stack Sets use to assume the roles in your child accounts. 116 - This defaults to C(arn:aws:iam::{{ account ID }}:role/AWSCloudFormationStackSetAdministrationRole) where C({{ account ID }}) is replaced with the 117 account number of the current IAM role/user/STS credentials. 118 aliases: 119 - admin_role_arn 120 - admin_role 121 - administration_role 122 type: str 123 execution_role_name: 124 description: 125 - ARN of the execution role, meaning the role that CloudFormation Stack Sets assumes in your child accounts. 126 - This MUST NOT be an ARN, and the roles must exist in each child account specified. 127 - The default name for the execution role is C(AWSCloudFormationStackSetExecutionRole) 128 aliases: 129 - exec_role_name 130 - exec_role 131 - execution_role 132 type: str 133 tags: 134 description: 135 - Dictionary of tags to associate with stack and its resources during stack creation. 136 - Can be updated later, updating tags removes previous entries. 137 type: dict 138 failure_tolerance: 139 description: 140 - Settings to change what is considered "failed" when running stack instance updates, and how many to do at a time. 141 type: dict 142 suboptions: 143 fail_count: 144 description: 145 - The number of accounts, per region, for which this operation can fail before CloudFormation 146 stops the operation in that region. 147 - You must specify one of I(fail_count) and I(fail_percentage). 148 type: int 149 fail_percentage: 150 type: int 151 description: 152 - The percentage of accounts, per region, for which this stack operation can fail before CloudFormation 153 stops the operation in that region. 154 - You must specify one of I(fail_count) and I(fail_percentage). 155 parallel_percentage: 156 type: int 157 description: 158 - The maximum percentage of accounts in which to perform this operation at one time. 159 - You must specify one of I(parallel_count) and I(parallel_percentage). 160 - Note that this setting lets you specify the maximum for operations. 161 For large deployments, under certain circumstances the actual percentage may be lower. 162 parallel_count: 163 type: int 164 description: 165 - The maximum number of accounts in which to perform this operation at one time. 166 - I(parallel_count) may be at most one more than the I(fail_count). 167 - You must specify one of I(parallel_count) and I(parallel_percentage). 168 - Note that this setting lets you specify the maximum for operations. 169 For large deployments, under certain circumstances the actual count may be lower. 170 171author: "Ryan Scott Brown (@ryansb)" 172extends_documentation_fragment: 173- amazon.aws.aws 174- amazon.aws.ec2 175 176requirements: [ boto3>=1.6, botocore>=1.10.26 ] 177''' 178 179EXAMPLES = r''' 180- name: Create a stack set with instances in two accounts 181 community.aws.cloudformation_stack_set: 182 name: my-stack 183 description: Test stack in two accounts 184 state: present 185 template_url: https://s3.amazonaws.com/my-bucket/cloudformation.template 186 accounts: [1234567890, 2345678901] 187 regions: 188 - us-east-1 189 190- name: on subsequent calls, templates are optional but parameters and tags can be altered 191 community.aws.cloudformation_stack_set: 192 name: my-stack 193 state: present 194 parameters: 195 InstanceName: my_stacked_instance 196 tags: 197 foo: bar 198 test: stack 199 accounts: [1234567890, 2345678901] 200 regions: 201 - us-east-1 202 203- name: The same type of update, but wait for the update to complete in all stacks 204 community.aws.cloudformation_stack_set: 205 name: my-stack 206 state: present 207 wait: true 208 parameters: 209 InstanceName: my_restacked_instance 210 tags: 211 foo: bar 212 test: stack 213 accounts: [1234567890, 2345678901] 214 regions: 215 - us-east-1 216''' 217 218RETURN = r''' 219operations_log: 220 type: list 221 description: Most recent events in CloudFormation's event log. This may be from a previous run in some cases. 222 returned: always 223 sample: 224 - action: CREATE 225 creation_timestamp: '2018-06-18T17:40:46.372000+00:00' 226 end_timestamp: '2018-06-18T17:41:24.560000+00:00' 227 operation_id: Ansible-StackInstance-Create-0ff2af5b-251d-4fdb-8b89-1ee444eba8b8 228 status: FAILED 229 stack_instances: 230 - account: '1234567890' 231 region: us-east-1 232 stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 233 status: OUTDATED 234 status_reason: Account 1234567890 should have 'AWSCloudFormationStackSetAdministrationRole' role with trust relationship to CloudFormation service. 235 236operations: 237 description: All operations initiated by this run of the cloudformation_stack_set module 238 returned: always 239 type: list 240 sample: 241 - action: CREATE 242 administration_role_arn: arn:aws:iam::1234567890:role/AWSCloudFormationStackSetAdministrationRole 243 creation_timestamp: '2018-06-18T17:40:46.372000+00:00' 244 end_timestamp: '2018-06-18T17:41:24.560000+00:00' 245 execution_role_name: AWSCloudFormationStackSetExecutionRole 246 operation_id: Ansible-StackInstance-Create-0ff2af5b-251d-4fdb-8b89-1ee444eba8b8 247 operation_preferences: 248 region_order: 249 - us-east-1 250 - us-east-2 251 stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 252 status: FAILED 253stack_instances: 254 description: CloudFormation stack instances that are members of this stack set. This will also include their region and account ID. 255 returned: state == present 256 type: list 257 sample: 258 - account: '1234567890' 259 region: us-east-1 260 stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 261 status: OUTDATED 262 status_reason: > 263 Account 1234567890 should have 'AWSCloudFormationStackSetAdministrationRole' role with trust relationship to CloudFormation service. 264 - account: '1234567890' 265 region: us-east-2 266 stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 267 status: OUTDATED 268 status_reason: Cancelled since failure tolerance has exceeded 269stack_set: 270 type: dict 271 description: Facts about the currently deployed stack set, its parameters, and its tags 272 returned: state == present 273 sample: 274 administration_role_arn: arn:aws:iam::1234567890:role/AWSCloudFormationStackSetAdministrationRole 275 capabilities: [] 276 description: test stack PRIME 277 execution_role_name: AWSCloudFormationStackSetExecutionRole 278 parameters: [] 279 stack_set_arn: arn:aws:cloudformation:us-east-1:1234567890:stackset/TestStackPrime:19f3f684-aae9-467-ba36-e09f92cf5929 280 stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 281 stack_set_name: TestStackPrime 282 status: ACTIVE 283 tags: 284 Some: Thing 285 an: other 286 template_body: | 287 AWSTemplateFormatVersion: "2010-09-09" 288 Parameters: {} 289 Resources: 290 Bukkit: 291 Type: "AWS::S3::Bucket" 292 Properties: {} 293 other: 294 Type: "AWS::SNS::Topic" 295 Properties: {} 296 297''' # NOQA 298 299import datetime 300import itertools 301import time 302import uuid 303 304try: 305 from botocore.exceptions import ClientError, BotoCoreError 306except ImportError: 307 # handled by AnsibleAWSModule 308 pass 309 310from ansible.module_utils._text import to_native 311from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict 312 313from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule 314from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code 315from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry 316from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_tag_list 317from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_tag_list_to_ansible_dict 318 319 320def create_stack_set(module, stack_params, cfn): 321 try: 322 cfn.create_stack_set(aws_retry=True, **stack_params) 323 return await_stack_set_exists(cfn, stack_params['StackSetName']) 324 except (ClientError, BotoCoreError) as err: 325 module.fail_json_aws(err, msg="Failed to create stack set {0}.".format(stack_params.get('StackSetName'))) 326 327 328def update_stack_set(module, stack_params, cfn): 329 # if the state is present and the stack already exists, we try to update it. 330 # AWS will tell us if the stack template and parameters are the same and 331 # don't need to be updated. 332 try: 333 cfn.update_stack_set(**stack_params) 334 except is_boto3_error_code('StackSetNotFound') as err: # pylint: disable=duplicate-except 335 module.fail_json_aws(err, msg="Failed to find stack set. Check the name & region.") 336 except is_boto3_error_code('StackInstanceNotFound') as err: # pylint: disable=duplicate-except 337 module.fail_json_aws(err, msg="One or more stack instances were not found for this stack set. Double check " 338 "the `accounts` and `regions` parameters.") 339 except is_boto3_error_code('OperationInProgressException') as err: # pylint: disable=duplicate-except 340 module.fail_json_aws( 341 err, msg="Another operation is already in progress on this stack set - please try again later. When making " 342 "multiple cloudformation_stack_set calls, it's best to enable `wait: yes` to avoid unfinished op errors.") 343 except (ClientError, BotoCoreError) as err: # pylint: disable=duplicate-except 344 module.fail_json_aws(err, msg="Could not update stack set.") 345 if module.params.get('wait'): 346 await_stack_set_operation( 347 module, cfn, operation_id=stack_params['OperationId'], 348 stack_set_name=stack_params['StackSetName'], 349 max_wait=module.params.get('wait_timeout'), 350 ) 351 352 return True 353 354 355def compare_stack_instances(cfn, stack_set_name, accounts, regions): 356 instance_list = cfn.list_stack_instances( 357 aws_retry=True, 358 StackSetName=stack_set_name, 359 )['Summaries'] 360 desired_stack_instances = set(itertools.product(accounts, regions)) 361 existing_stack_instances = set((i['Account'], i['Region']) for i in instance_list) 362 # new stacks, existing stacks, unspecified stacks 363 return (desired_stack_instances - existing_stack_instances), existing_stack_instances, (existing_stack_instances - desired_stack_instances) 364 365 366@AWSRetry.backoff(tries=3, delay=4) 367def stack_set_facts(cfn, stack_set_name): 368 try: 369 ss = cfn.describe_stack_set(StackSetName=stack_set_name)['StackSet'] 370 ss['Tags'] = boto3_tag_list_to_ansible_dict(ss['Tags']) 371 return ss 372 except cfn.exceptions.from_code('StackSetNotFound'): 373 # Return None if the stack doesn't exist 374 return 375 376 377def await_stack_set_operation(module, cfn, stack_set_name, operation_id, max_wait): 378 wait_start = datetime.datetime.now() 379 operation = None 380 for i in range(max_wait // 15): 381 try: 382 operation = cfn.describe_stack_set_operation(StackSetName=stack_set_name, OperationId=operation_id) 383 if operation['StackSetOperation']['Status'] not in ('RUNNING', 'STOPPING'): 384 # Stack set has completed operation 385 break 386 except is_boto3_error_code('StackSetNotFound'): # pylint: disable=duplicate-except 387 pass 388 except is_boto3_error_code('OperationNotFound'): # pylint: disable=duplicate-except 389 pass 390 time.sleep(15) 391 392 if operation and operation['StackSetOperation']['Status'] not in ('FAILED', 'STOPPED'): 393 await_stack_instance_completion( 394 module, cfn, 395 stack_set_name=stack_set_name, 396 # subtract however long we waited already 397 max_wait=int(max_wait - (datetime.datetime.now() - wait_start).total_seconds()), 398 ) 399 elif operation and operation['StackSetOperation']['Status'] in ('FAILED', 'STOPPED'): 400 pass 401 else: 402 module.warn( 403 "Timed out waiting for operation {0} on stack set {1} after {2} seconds. Returning unfinished operation".format( 404 operation_id, stack_set_name, max_wait 405 ) 406 ) 407 408 409def await_stack_instance_completion(module, cfn, stack_set_name, max_wait): 410 to_await = None 411 for i in range(max_wait // 15): 412 try: 413 stack_instances = cfn.list_stack_instances(StackSetName=stack_set_name) 414 to_await = [inst for inst in stack_instances['Summaries'] 415 if inst['Status'] != 'CURRENT'] 416 if not to_await: 417 return stack_instances['Summaries'] 418 except is_boto3_error_code('StackSetNotFound'): # pylint: disable=duplicate-except 419 # this means the deletion beat us, or the stack set is not yet propagated 420 pass 421 time.sleep(15) 422 423 module.warn( 424 "Timed out waiting for stack set {0} instances {1} to complete after {2} seconds. Returning unfinished operation".format( 425 stack_set_name, ', '.join(s['StackId'] for s in to_await), max_wait 426 ) 427 ) 428 429 430def await_stack_set_exists(cfn, stack_set_name): 431 # AWSRetry will retry on `StackSetNotFound` errors for us 432 ss = cfn.describe_stack_set(StackSetName=stack_set_name, aws_retry=True)['StackSet'] 433 ss['Tags'] = boto3_tag_list_to_ansible_dict(ss['Tags']) 434 return camel_dict_to_snake_dict(ss, ignore_list=('Tags',)) 435 436 437def describe_stack_tree(module, stack_set_name, operation_ids=None): 438 jittered_backoff_decorator = AWSRetry.jittered_backoff(retries=5, delay=3, max_delay=5, catch_extra_error_codes=['StackSetNotFound']) 439 cfn = module.client('cloudformation', retry_decorator=jittered_backoff_decorator) 440 result = dict() 441 result['stack_set'] = camel_dict_to_snake_dict( 442 cfn.describe_stack_set( 443 StackSetName=stack_set_name, 444 aws_retry=True, 445 )['StackSet'] 446 ) 447 result['stack_set']['tags'] = boto3_tag_list_to_ansible_dict(result['stack_set']['tags']) 448 result['operations_log'] = sorted( 449 camel_dict_to_snake_dict( 450 cfn.list_stack_set_operations( 451 StackSetName=stack_set_name, 452 aws_retry=True, 453 ) 454 )['summaries'], 455 key=lambda x: x['creation_timestamp'] 456 ) 457 result['stack_instances'] = sorted( 458 [ 459 camel_dict_to_snake_dict(i) for i in 460 cfn.list_stack_instances(StackSetName=stack_set_name)['Summaries'] 461 ], 462 key=lambda i: i['region'] + i['account'] 463 ) 464 465 if operation_ids: 466 result['operations'] = [] 467 for op_id in operation_ids: 468 try: 469 result['operations'].append(camel_dict_to_snake_dict( 470 cfn.describe_stack_set_operation( 471 StackSetName=stack_set_name, 472 OperationId=op_id, 473 )['StackSetOperation'] 474 )) 475 except is_boto3_error_code('OperationNotFoundException'): # pylint: disable=duplicate-except 476 pass 477 return result 478 479 480def get_operation_preferences(module): 481 params = dict() 482 if module.params.get('regions'): 483 params['RegionOrder'] = list(module.params['regions']) 484 for param, api_name in { 485 'fail_count': 'FailureToleranceCount', 486 'fail_percentage': 'FailureTolerancePercentage', 487 'parallel_percentage': 'MaxConcurrentPercentage', 488 'parallel_count': 'MaxConcurrentCount', 489 }.items(): 490 if module.params.get('failure_tolerance', {}).get(param): 491 params[api_name] = module.params.get('failure_tolerance', {}).get(param) 492 return params 493 494 495def main(): 496 argument_spec = dict( 497 name=dict(required=True), 498 description=dict(), 499 wait=dict(type='bool', default=False), 500 wait_timeout=dict(type='int', default=900), 501 state=dict(default='present', choices=['present', 'absent']), 502 purge_stacks=dict(type='bool', default=True), 503 parameters=dict(type='dict', default={}), 504 template=dict(type='path'), 505 template_url=dict(), 506 template_body=dict(), 507 capabilities=dict(type='list', elements='str', choices=['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM']), 508 regions=dict(type='list', elements='str'), 509 accounts=dict(type='list', elements='str'), 510 failure_tolerance=dict( 511 type='dict', 512 default={}, 513 options=dict( 514 fail_count=dict(type='int'), 515 fail_percentage=dict(type='int'), 516 parallel_percentage=dict(type='int'), 517 parallel_count=dict(type='int'), 518 ), 519 mutually_exclusive=[ 520 ['fail_count', 'fail_percentage'], 521 ['parallel_count', 'parallel_percentage'], 522 ], 523 ), 524 administration_role_arn=dict(aliases=['admin_role_arn', 'administration_role', 'admin_role']), 525 execution_role_name=dict(aliases=['execution_role', 'exec_role', 'exec_role_name']), 526 tags=dict(type='dict'), 527 ) 528 529 module = AnsibleAWSModule( 530 argument_spec=argument_spec, 531 mutually_exclusive=[['template_url', 'template', 'template_body']], 532 supports_check_mode=True 533 ) 534 if not (module.boto3_at_least('1.6.0') and module.botocore_at_least('1.10.26')): 535 module.fail_json(msg="Boto3 or botocore version is too low. This module requires at least boto3 1.6 and botocore 1.10.26") 536 537 # Wrap the cloudformation client methods that this module uses with 538 # automatic backoff / retry for throttling error codes 539 jittered_backoff_decorator = AWSRetry.jittered_backoff(retries=10, delay=3, max_delay=30, catch_extra_error_codes=['StackSetNotFound']) 540 cfn = module.client('cloudformation', retry_decorator=jittered_backoff_decorator) 541 existing_stack_set = stack_set_facts(cfn, module.params['name']) 542 543 operation_uuid = to_native(uuid.uuid4()) 544 operation_ids = [] 545 # collect the parameters that are passed to boto3. Keeps us from having so many scalars floating around. 546 stack_params = {} 547 state = module.params['state'] 548 if state == 'present' and not module.params['accounts']: 549 module.fail_json( 550 msg="Can't create a stack set without choosing at least one account. " 551 "To get the ID of the current account, use the aws_caller_info module." 552 ) 553 554 module.params['accounts'] = [to_native(a) for a in module.params['accounts']] 555 556 stack_params['StackSetName'] = module.params['name'] 557 if module.params.get('description'): 558 stack_params['Description'] = module.params['description'] 559 560 if module.params.get('capabilities'): 561 stack_params['Capabilities'] = module.params['capabilities'] 562 563 if module.params['template'] is not None: 564 with open(module.params['template'], 'r') as tpl: 565 stack_params['TemplateBody'] = tpl.read() 566 elif module.params['template_body'] is not None: 567 stack_params['TemplateBody'] = module.params['template_body'] 568 elif module.params['template_url'] is not None: 569 stack_params['TemplateURL'] = module.params['template_url'] 570 else: 571 # no template is provided, but if the stack set exists already, we can use the existing one. 572 if existing_stack_set: 573 stack_params['UsePreviousTemplate'] = True 574 else: 575 module.fail_json( 576 msg="The Stack Set {0} does not exist, and no template was provided. Provide one of `template`, " 577 "`template_body`, or `template_url`".format(module.params['name']) 578 ) 579 580 stack_params['Parameters'] = [] 581 for k, v in module.params['parameters'].items(): 582 if isinstance(v, dict): 583 # set parameter based on a dict to allow additional CFN Parameter Attributes 584 param = dict(ParameterKey=k) 585 586 if 'value' in v: 587 param['ParameterValue'] = to_native(v['value']) 588 589 if 'use_previous_value' in v and bool(v['use_previous_value']): 590 param['UsePreviousValue'] = True 591 param.pop('ParameterValue', None) 592 593 stack_params['Parameters'].append(param) 594 else: 595 # allow default k/v configuration to set a template parameter 596 stack_params['Parameters'].append({'ParameterKey': k, 'ParameterValue': str(v)}) 597 598 if module.params.get('tags') and isinstance(module.params.get('tags'), dict): 599 stack_params['Tags'] = ansible_dict_to_boto3_tag_list(module.params['tags']) 600 601 if module.params.get('administration_role_arn'): 602 # TODO loosen the semantics here to autodetect the account ID and build the ARN 603 stack_params['AdministrationRoleARN'] = module.params['administration_role_arn'] 604 if module.params.get('execution_role_name'): 605 stack_params['ExecutionRoleName'] = module.params['execution_role_name'] 606 607 result = {} 608 609 if module.check_mode: 610 if state == 'absent' and existing_stack_set: 611 module.exit_json(changed=True, msg='Stack set would be deleted', meta=[]) 612 elif state == 'absent' and not existing_stack_set: 613 module.exit_json(changed=False, msg='Stack set doesn\'t exist', meta=[]) 614 elif state == 'present' and not existing_stack_set: 615 module.exit_json(changed=True, msg='New stack set would be created', meta=[]) 616 elif state == 'present' and existing_stack_set: 617 new_stacks, existing_stacks, unspecified_stacks = compare_stack_instances( 618 cfn, 619 module.params['name'], 620 module.params['accounts'], 621 module.params['regions'], 622 ) 623 if new_stacks: 624 module.exit_json(changed=True, msg='New stack instance(s) would be created', meta=[]) 625 elif unspecified_stacks and module.params.get('purge_stack_instances'): 626 module.exit_json(changed=True, msg='Old stack instance(s) would be deleted', meta=[]) 627 else: 628 # TODO: need to check the template and other settings for correct check mode 629 module.exit_json(changed=False, msg='No changes detected', meta=[]) 630 631 changed = False 632 if state == 'present': 633 if not existing_stack_set: 634 # on create this parameter has a different name, and cannot be referenced later in the job log 635 stack_params['ClientRequestToken'] = 'Ansible-StackSet-Create-{0}'.format(operation_uuid) 636 changed = True 637 create_stack_set(module, stack_params, cfn) 638 else: 639 stack_params['OperationId'] = 'Ansible-StackSet-Update-{0}'.format(operation_uuid) 640 operation_ids.append(stack_params['OperationId']) 641 if module.params.get('regions'): 642 stack_params['OperationPreferences'] = get_operation_preferences(module) 643 changed |= update_stack_set(module, stack_params, cfn) 644 645 # now create/update any appropriate stack instances 646 new_stack_instances, existing_stack_instances, unspecified_stack_instances = compare_stack_instances( 647 cfn, 648 module.params['name'], 649 module.params['accounts'], 650 module.params['regions'], 651 ) 652 if new_stack_instances: 653 operation_ids.append('Ansible-StackInstance-Create-{0}'.format(operation_uuid)) 654 changed = True 655 cfn.create_stack_instances( 656 StackSetName=module.params['name'], 657 Accounts=list(set(acct for acct, region in new_stack_instances)), 658 Regions=list(set(region for acct, region in new_stack_instances)), 659 OperationPreferences=get_operation_preferences(module), 660 OperationId=operation_ids[-1], 661 ) 662 else: 663 operation_ids.append('Ansible-StackInstance-Update-{0}'.format(operation_uuid)) 664 cfn.update_stack_instances( 665 StackSetName=module.params['name'], 666 Accounts=list(set(acct for acct, region in existing_stack_instances)), 667 Regions=list(set(region for acct, region in existing_stack_instances)), 668 OperationPreferences=get_operation_preferences(module), 669 OperationId=operation_ids[-1], 670 ) 671 for op in operation_ids: 672 await_stack_set_operation( 673 module, cfn, operation_id=op, 674 stack_set_name=module.params['name'], 675 max_wait=module.params.get('wait_timeout'), 676 ) 677 678 elif state == 'absent': 679 if not existing_stack_set: 680 module.exit_json(msg='Stack set {0} does not exist'.format(module.params['name'])) 681 if module.params.get('purge_stack_instances') is False: 682 pass 683 try: 684 cfn.delete_stack_set( 685 StackSetName=module.params['name'], 686 ) 687 module.exit_json(msg='Stack set {0} deleted'.format(module.params['name'])) 688 except is_boto3_error_code('OperationInProgressException') as e: # pylint: disable=duplicate-except 689 module.fail_json_aws(e, msg='Cannot delete stack {0} while there is an operation in progress'.format(module.params['name'])) 690 except is_boto3_error_code('StackSetNotEmptyException'): # pylint: disable=duplicate-except 691 delete_instances_op = 'Ansible-StackInstance-Delete-{0}'.format(operation_uuid) 692 cfn.delete_stack_instances( 693 StackSetName=module.params['name'], 694 Accounts=module.params['accounts'], 695 Regions=module.params['regions'], 696 RetainStacks=(not module.params.get('purge_stacks')), 697 OperationId=delete_instances_op 698 ) 699 await_stack_set_operation( 700 module, cfn, operation_id=delete_instances_op, 701 stack_set_name=stack_params['StackSetName'], 702 max_wait=module.params.get('wait_timeout'), 703 ) 704 try: 705 cfn.delete_stack_set( 706 StackSetName=module.params['name'], 707 ) 708 except is_boto3_error_code('StackSetNotEmptyException') as exc: # pylint: disable=duplicate-except 709 # this time, it is likely that either the delete failed or there are more stacks. 710 instances = cfn.list_stack_instances( 711 StackSetName=module.params['name'], 712 ) 713 stack_states = ', '.join('(account={Account}, region={Region}, state={Status})'.format(**i) for i in instances['Summaries']) 714 module.fail_json_aws(exc, msg='Could not purge all stacks, or not all accounts/regions were chosen for deletion: ' + stack_states) 715 module.exit_json(changed=True, msg='Stack set {0} deleted'.format(module.params['name'])) 716 717 result.update(**describe_stack_tree(module, stack_params['StackSetName'], operation_ids=operation_ids)) 718 if any(o['status'] == 'FAILED' for o in result['operations']): 719 module.fail_json(msg="One or more operations failed to execute", **result) 720 module.exit_json(changed=changed, **result) 721 722 723if __name__ == '__main__': 724 main() 725