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