From 6d52afeed633bf2120631c7a46cd6f4e01ad9fff Mon Sep 17 00:00:00 2001 From: Ryan Brown Date: Mon, 20 Aug 2018 14:38:14 -0400 Subject: [PATCH] New module cloudformation_stack_set (#41669) * [AWS] new module cloudformation_stack_set with integration tests --- lib/ansible/module_utils/aws/core.py | 2 +- .../cloud/amazon/cloudformation_stack_set.py | 672 ++++++++++++++++++ .../targets/cloudformation_stack_set/aliases | 2 + .../files/test_bucket_stack.yml | 6 + .../files/test_modded_bucket_stack.yml | 9 + .../playbooks/full_test.yml | 5 + .../targets/cloudformation_stack_set/runme.sh | 19 + .../cloudformation_stack_set/tasks/main.yml | 186 +++++ 8 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/cloud/amazon/cloudformation_stack_set.py create mode 100644 test/integration/targets/cloudformation_stack_set/aliases create mode 100644 test/integration/targets/cloudformation_stack_set/files/test_bucket_stack.yml create mode 100644 test/integration/targets/cloudformation_stack_set/files/test_modded_bucket_stack.yml create mode 100644 test/integration/targets/cloudformation_stack_set/playbooks/full_test.yml create mode 100755 test/integration/targets/cloudformation_stack_set/runme.sh create mode 100644 test/integration/targets/cloudformation_stack_set/tasks/main.yml diff --git a/lib/ansible/module_utils/aws/core.py b/lib/ansible/module_utils/aws/core.py index 56fe93b646b..aa54018df8e 100644 --- a/lib/ansible/module_utils/aws/core.py +++ b/lib/ansible/module_utils/aws/core.py @@ -69,7 +69,7 @@ from ansible.module_utils._text import to_native from ansible.module_utils.ec2 import HAS_BOTO3, camel_dict_to_snake_dict, ec2_argument_spec, boto3_conn, get_aws_connection_info # We will also export HAS_BOTO3 so end user modules can use it. -__all__ = ('AnsibleAWSModule', 'HAS_BOTO3',) +__all__ = ('AnsibleAWSModule', 'HAS_BOTO3', 'is_boto3_error_code') class AnsibleAWSModule(object): diff --git a/lib/ansible/modules/cloud/amazon/cloudformation_stack_set.py b/lib/ansible/modules/cloud/amazon/cloudformation_stack_set.py new file mode 100644 index 00000000000..b2ae3cc1e46 --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/cloudformation_stack_set.py @@ -0,0 +1,672 @@ +#!/usr/bin/python +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: cloudformation_stack_set +short_description: Manage groups of CloudFormation stacks +description: + - Launches/updates/deletes AWS CloudFormation Stack Sets +notes: + - To make an individual stack, you want the cloudformation module. +version_added: "2.7" +options: + name: + description: + - name of the cloudformation stack set + required: true + description: + description: + - A description of what this stack set creates + parameters: + description: + - A list of hashes of all the template variables for the stack. The value can be a string or a dict. + - Dict can be used to set additional template parameter attributes like UsePreviousValue (see example). + default: {} + state: + description: + - If state is "present", stack will be created. If state is "present" and if stack exists and template has changed, it will be updated. + If state is "absent", stack will be removed. + default: present + choices: [ present, absent ] + template: + description: + - The local path of the cloudformation template. + - This must be the full path to the file, relative to the working directory. If using roles this may look + like "roles/cloudformation/files/cloudformation-example.json". + - If 'state' is 'present' and the stack does not exist yet, either 'template', 'template_body' or 'template_url' + must be specified (but only one of them). If 'state' is present, the stack does exist, and neither 'template', + 'template_body' nor 'template_url' are specified, the previous template will be reused. + template_body: + description: + - Template body. Use this to pass in the actual body of the Cloudformation template. + - If 'state' is 'present' and the stack does not exist yet, either 'template', 'template_body' or 'template_url' + must be specified (but only one of them). If 'state' is present, the stack does exist, and neither 'template', + 'template_body' nor 'template_url' are specified, the previous template will be reused. + template_url: + description: + - 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 + as the stack. + - If 'state' is 'present' and the stack does not exist yet, either 'template', 'template_body' or 'template_url' + must be specified (but only one of them). If 'state' is present, the stack does exist, and neither 'template', + 'template_body' nor 'template_url' are specified, the previous template will be reused. + purge_stacks: + description: + - Only applicable when I(state=absent). Sets whether, when deleting a stack set, the stack instances should also be deleted. + - By default, instances will be deleted. Set to 'no' or 'false' to keep stacks when stack set is deleted. + type: bool + default: true + wait: + description: + - Whether or not to wait for stack operation to complete. This includes waiting for stack instances to reach UPDATE_COMPLETE status. + - If you choose not to wait, this module will not notify when stack operations fail because it will not wait for them to finish. + type: bool + default: false + wait_timeout: + description: + - How long to wait (in seconds) for stacks to complete create/update/delete operations. + default: 900 + capabilities: + description: + - Capabilities allow stacks to create and modify IAM resources, which may include adding users or roles. + - Currently the only available values are 'CAPABILITY_IAM' and 'CAPABILITY_NAMED_IAM'. Either or both may be provided. + - > + The following resources require that one or both of these parameters is specified: AWS::IAM::AccessKey, + AWS::IAM::Group, AWS::IAM::InstanceProfile, AWS::IAM::Policy, AWS::IAM::Role, AWS::IAM::User, AWS::IAM::UserToGroupAddition + choices: + - 'CAPABILITY_IAM' + - 'CAPABILITY_NAMED_IAM' + regions: + description: + - 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) + specifies the region for stack instances. + - At least one region must be specified to create a stack set. On updates, if fewer regions are specified only the specified regions will + have their stack instances updated. + accounts: + description: + - A list of AWS accounts in which to create instance of CloudFormation stacks. + - At least one region must be specified to create a stack set. On updates, if fewer regions are specified only the specified regions will + have their stack instances updated. + administration_role_arn: + description: + - ARN of the administration role, meaning the role that CloudFormation Stack Sets use to assume the roles in your child accounts. + - This defaults to I(arn:aws:iam::{{ account ID }}:role/AWSCloudFormationStackSetAdministrationRole) where I({{ account ID }}) is replaced with the + account number of the current IAM role/user/STS credentials. + aliases: + - admin_role_arn + - admin_role + - administration_role + execution_role_name: + description: + - ARN of the execution role, meaning the role that CloudFormation Stack Sets assumes in your child accounts. + - This MUST NOT be an ARN, and the roles must exist in each child account specified. + - The default name for the execution role is I(AWSCloudFormationStackSetExecutionRole) + aliases: + - exec_role_name + - exec_role + - execution_role + tags: + description: + - Dictionary of tags to associate with stack and its resources during stack creation. Can be updated later, updating tags removes previous entries. + failure_tolerance: + description: + - Settings to change what is considered "failed" when running stack instance updates, and how many to do at a time. + +author: "Ryan Scott Brown (@ryansb)" +extends_documentation_fragment: +- aws +- ec2 +requirements: [ boto3>=1.6, botocore>=1.10.26 ] +''' + +EXAMPLES = ''' +- name: Create a stack set with instances in two accounts + cloudformation_stack_set: + name: my-stack + description: Test stack in two accounts + state: present + template_url: https://s3.amazonaws.com/my-bucket/cloudformation.template + accounts: [1234567890, 2345678901] + regions: + - us-east-1 + +- name: on subsequent calls, templates are optional but parameters and tags can be altered + cloudformation_stack_set: + name: my-stack + state: present + parameters: + InstanceName: my_stacked_instance + tags: + foo: bar + test: stack + accounts: [1234567890, 2345678901] + regions: + - us-east-1 + +- name: The same type of update, but wait for the update to complete in all stacks + cloudformation_stack_set: + name: my-stack + state: present + wait: true + parameters: + InstanceName: my_restacked_instance + tags: + foo: bar + test: stack + accounts: [1234567890, 2345678901] + regions: + - us-east-1 +''' + +RETURN = ''' +operations_log: + type: list + description: Most recent events in Cloudformation's event log. This may be from a previous run in some cases. + returned: always + sample: + - action: CREATE + creation_timestamp: '2018-06-18T17:40:46.372000+00:00' + end_timestamp: '2018-06-18T17:41:24.560000+00:00' + operation_id: Ansible-StackInstance-Create-0ff2af5b-251d-4fdb-8b89-1ee444eba8b8 + status: FAILED + stack_instances: + - account: '1234567890' + region: us-east-1 + stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 + status: OUTDATED + status_reason: Account 1234567890 should have 'AWSCloudFormationStackSetAdministrationRole' role with trust relationship to CloudFormation service. + +operations: + description: All operations initiated by this run of the cloudformation_stack_set module + returned: always + type: list + sample: + - action: CREATE + administration_role_arn: arn:aws:iam::1234567890:role/AWSCloudFormationStackSetAdministrationRole + creation_timestamp: '2018-06-18T17:40:46.372000+00:00' + end_timestamp: '2018-06-18T17:41:24.560000+00:00' + execution_role_name: AWSCloudFormationStackSetExecutionRole + operation_id: Ansible-StackInstance-Create-0ff2af5b-251d-4fdb-8b89-1ee444eba8b8 + operation_preferences: + region_order: + - us-east-1 + - us-east-2 + stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 + status: FAILED +stack_instances: + description: CloudFormation stack instances that are members of this stack set. This will also include their region and account ID. + returned: state == present + type: list + sample: + - account: '1234567890' + region: us-east-1 + stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 + status: OUTDATED + status_reason: > + Account 1234567890 should have 'AWSCloudFormationStackSetAdministrationRole' role with trust relationship to CloudFormation service. + - account: '1234567890' + region: us-east-2 + stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 + status: OUTDATED + status_reason: Cancelled since failure tolerance has exceeded +stack_set: + type: dict + description: Facts about the currently deployed stack set, its parameters, and its tags + returned: state == present + sample: + administration_role_arn: arn:aws:iam::1234567890:role/AWSCloudFormationStackSetAdministrationRole + capabilities: [] + description: test stack PRIME + execution_role_name: AWSCloudFormationStackSetExecutionRole + parameters: [] + stack_set_arn: arn:aws:cloudformation:us-east-1:1234567890:stackset/TestStackPrime:19f3f684-aae9-467-ba36-e09f92cf5929 + stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929 + stack_set_name: TestStackPrime + status: ACTIVE + tags: + Some: Thing + an: other + template_body: | + AWSTemplateFormatVersion: "2010-09-09" + Parameters: {} + Resources: + Bukkit: + Type: "AWS::S3::Bucket" + Properties: {} + other: + Type: "AWS::SNS::Topic" + Properties: {} + +''' # NOQA + +import time +import datetime +import uuid +import itertools + +try: + import boto3 + import botocore.exceptions + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + # handled by AnsibleAWSModule + pass + +from ansible.module_utils.ec2 import AWSRetry, boto3_tag_list_to_ansible_dict, ansible_dict_to_boto3_tag_list, camel_dict_to_snake_dict +from ansible.module_utils.aws.core import AnsibleAWSModule, is_boto3_error_code +from ansible.module_utils._text import to_native + + +def create_stack_set(module, stack_params, cfn): + try: + cfn.create_stack_set(aws_retry=True, **stack_params) + return await_stack_set_exists(cfn, stack_params['StackSetName']) + except (ClientError, BotoCoreError) as err: + module.fail_json_aws(err, msg="Failed to create stack set {0}.".format(stack_params.get('StackSetName'))) + + +def update_stack_set(module, stack_params, cfn): + # if the state is present and the stack already exists, we try to update it. + # AWS will tell us if the stack template and parameters are the same and + # don't need to be updated. + try: + cfn.update_stack_set(**stack_params) + except is_boto3_error_code('StackSetNotFound') as err: # pylint: disable=duplicate-except + module.fail_json_aws(err, msg="Failed to find stack set. Check the name & region.") + except is_boto3_error_code('StackInstanceNotFound') as err: # pylint: disable=duplicate-except + module.fail_json_aws(err, msg="One or more stack instances were not found for this stack set. Double check " + "the `accounts` and `regions` parameters.") + except is_boto3_error_code('OperationInProgressException') as err: # pylint: disable=duplicate-except + module.fail_json_aws( + err, msg="Another operation is already in progress on this stack set - please try again later. When making " + "multiple cloudformation_stack_set calls, it's best to enable `wait: yes` to avoid unfinished op errors.") + except (ClientError, BotoCoreError) as err: # pylint: disable=duplicate-except + module.fail_json_aws(err, msg="Could not update stack set.") + if module.params.get('wait'): + await_stack_set_operation( + module, cfn, operation_id=stack_params['OperationId'], + stack_set_name=stack_params['StackSetName'], + max_wait=module.params.get('wait_timeout'), + ) + + return True + + +def compare_stack_instances(cfn, stack_set_name, accounts, regions): + instance_list = cfn.list_stack_instances( + aws_retry=True, + StackSetName=stack_set_name, + )['Summaries'] + desired_stack_instances = set(itertools.product(accounts, regions)) + existing_stack_instances = set((i['Account'], i['Region']) for i in instance_list) + # new stacks, existing stacks, unspecified stacks + return (desired_stack_instances - existing_stack_instances), existing_stack_instances, (existing_stack_instances - desired_stack_instances) + + +@AWSRetry.backoff(tries=3, delay=4) +def stack_set_facts(cfn, stack_set_name): + try: + ss = cfn.describe_stack_set(StackSetName=stack_set_name)['StackSet'] + ss['Tags'] = boto3_tag_list_to_ansible_dict(ss['Tags']) + return ss + except cfn.exceptions.from_code('StackSetNotFound'): + # catch NotFound error before the retry kicks in to avoid waiting + # if the stack does not exist + return + + +def await_stack_set_operation(module, cfn, stack_set_name, operation_id, max_wait): + wait_start = datetime.datetime.now() + operation = None + for i in range(max_wait // 15): + try: + operation = cfn.describe_stack_set_operation(StackSetName=stack_set_name, OperationId=operation_id) + if operation['StackSetOperation']['Status'] not in ('RUNNING', 'STOPPING'): + # Stack set has completed operation + break + except is_boto3_error_code('StackSetNotFound'): # pylint: disable=duplicate-except + pass + except is_boto3_error_code('OperationNotFound'): # pylint: disable=duplicate-except + pass + time.sleep(15) + + if operation and operation['StackSetOperation']['Status'] not in ('FAILED', 'STOPPED'): + await_stack_instance_completion( + module, cfn, + stack_set_name=stack_set_name, + # subtract however long we waited already + max_wait=int(max_wait - (datetime.datetime.now() - wait_start).total_seconds()), + ) + elif operation and operation['StackSetOperation']['Status'] in ('FAILED', 'STOPPED'): + pass + else: + module.warn( + "Timed out waiting for operation {0} on stack set {1} after {2} seconds. Returning unfinished operation".format( + operation_id, stack_set_name, max_wait + ) + ) + + +def await_stack_instance_completion(module, cfn, stack_set_name, max_wait): + to_await = None + for i in range(max_wait // 15): + try: + stack_instances = cfn.list_stack_instances(StackSetName=stack_set_name) + to_await = [inst for inst in stack_instances['Summaries'] + if inst['Status'] != 'CURRENT'] + if not to_await: + return stack_instances['Summaries'] + except is_boto3_error_code('StackSetNotFound'): # pylint: disable=duplicate-except + # this means the deletion beat us, or the stack set is not yet propagated + pass + time.sleep(15) + + module.warn( + "Timed out waiting for stack set {0} instances {1} to complete after {2} seconds. Returning unfinished operation".format( + stack_set_name, ', '.join(s['StackId'] for s in to_await), max_wait + ) + ) + + +def await_stack_set_exists(cfn, stack_set_name): + # AWSRetry will retry on `NotFound` errors for us + ss = cfn.describe_stack_set(StackSetName=stack_set_name, aws_retry=True)['StackSet'] + ss['Tags'] = boto3_tag_list_to_ansible_dict(ss['Tags']) + return camel_dict_to_snake_dict(ss, ignore_list=('Tags',)) + + +def describe_stack_tree(module, stack_set_name, operation_ids=None): + cfn = module.client('cloudformation', retry_decorator=AWSRetry.jittered_backoff(retries=5, delay=3, max_delay=5)) + result = dict() + result['stack_set'] = camel_dict_to_snake_dict( + cfn.describe_stack_set( + StackSetName=stack_set_name, + aws_retry=True, + )['StackSet'] + ) + result['stack_set']['tags'] = boto3_tag_list_to_ansible_dict(result['stack_set']['tags']) + result['operations_log'] = sorted( + camel_dict_to_snake_dict( + cfn.list_stack_set_operations( + StackSetName=stack_set_name, + aws_retry=True, + ) + )['summaries'], + key=lambda x: x['creation_timestamp'] + ) + result['stack_instances'] = sorted( + [ + camel_dict_to_snake_dict(i) for i in + cfn.list_stack_instances(StackSetName=stack_set_name)['Summaries'] + ], + key=lambda i: i['region'] + i['account'] + ) + + if operation_ids: + result['operations'] = [] + for op_id in operation_ids: + try: + result['operations'].append(camel_dict_to_snake_dict( + cfn.describe_stack_set_operation( + StackSetName=stack_set_name, + OperationId=op_id, + )['StackSetOperation'] + )) + except is_boto3_error_code('OperationNotFoundException'): # pylint: disable=duplicate-except + pass + return result + + +def get_operation_preferences(module): + params = dict() + if module.params.get('regions'): + params['RegionOrder'] = list(module.params['regions']) + for param, api_name in { + 'fail_count': 'FailureToleranceCount', + 'fail_percentage': 'FailureTolerancePercentage', + 'parallel_percentage': 'MaxConcurrentPercentage', + 'parallel_count': 'MaxConcurrentCount', + }.items(): + if module.params.get('failure_tolerance', {}).get(param): + params[api_name] = module.params.get('failure_tolerance', {}).get(param) + return params + + +def main(): + argument_spec = dict( + name=dict(required=True), + description=dict(), + wait=dict(type='bool', default=False), + wait_timeout=dict(type='int', default=900), + state=dict(default='present', choices=['present', 'absent']), + purge_stacks=dict(type='bool', default=True), + parameters=dict(type='dict', default={}), + template=dict(type='path'), + template_url=dict(), + template_body=dict(), + capabilities=dict(type='list', choices=['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM']), + regions=dict(type='list'), + accounts=dict(type='list'), + failure_tolerance=dict( + type='dict', + default={}, + options=dict( + fail_count=dict(type='int'), + fail_percentage=dict(type='int'), + parallel_percentage=dict(type='int'), + parallel_count=dict(type='int'), + ), + mutually_exclusive=[ + ['fail_count', 'fail_percentage'], + ['parallel_count', 'parallel_percentage'], + ], + ), + administration_role_arn=dict(aliases=['admin_role_arn', 'administration_role', 'admin_role']), + execution_role_name=dict(aliases=['execution_role', 'exec_role', 'exec_role_name']), + tags=dict(type='dict'), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + mutually_exclusive=[['template_url', 'template', 'template_body']], + supports_check_mode=True + ) + if not (module.boto3_at_least('1.6.0') and module.botocore_at_least('1.10.26')): + module.fail_json(msg="Boto3 or botocore version is too low. This module requires at least boto3 1.6 and botocore 1.10.26") + + # Wrap the cloudformation client methods that this module uses with + # automatic backoff / retry for throttling error codes + cfn = module.client('cloudformation', retry_decorator=AWSRetry.jittered_backoff(retries=10, delay=3, max_delay=30)) + existing_stack_set = stack_set_facts(cfn, module.params['name']) + + operation_uuid = to_native(uuid.uuid4()) + operation_ids = [] + # collect the parameters that are passed to boto3. Keeps us from having so many scalars floating around. + stack_params = {} + state = module.params['state'] + if state == 'present' and not module.params['accounts']: + module.fail_json( + msg="Can't create a stack set without choosing at least one account. " + "To get the ID of the current account, use the aws_caller_facts module." + ) + + module.params['accounts'] = [to_native(a) for a in module.params['accounts']] + + stack_params['StackSetName'] = module.params['name'] + if module.params.get('description'): + stack_params['Description'] = module.params['description'] + + if module.params.get('capabilities'): + stack_params['Capabilities'] = module.params['capabilities'] + + if module.params['template'] is not None: + with open(module.params['template'], 'r') as tpl: + stack_params['TemplateBody'] = tpl.read() + elif module.params['template_body'] is not None: + stack_params['TemplateBody'] = module.params['template_body'] + elif module.params['template_url'] is not None: + stack_params['TemplateURL'] = module.params['template_url'] + else: + # no template is provided, but if the stack set exists already, we can use the existing one. + if existing_stack_set: + stack_params['UsePreviousTemplate'] = True + else: + module.fail_json( + msg="The Stack Set {0} does not exist, and no template was provided. Provide one of `template`, " + "`template_body`, or `template_url`".format(module.params['name']) + ) + + stack_params['Parameters'] = [] + for k, v in module.params['parameters'].items(): + if isinstance(v, dict): + # set parameter based on a dict to allow additional CFN Parameter Attributes + param = dict(ParameterKey=k) + + if 'value' in v: + param['ParameterValue'] = to_native(v['value']) + + if 'use_previous_value' in v and bool(v['use_previous_value']): + param['UsePreviousValue'] = True + param.pop('ParameterValue', None) + + stack_params['Parameters'].append(param) + else: + # allow default k/v configuration to set a template parameter + stack_params['Parameters'].append({'ParameterKey': k, 'ParameterValue': str(v)}) + + if module.params.get('tags') and isinstance(module.params.get('tags'), dict): + stack_params['Tags'] = ansible_dict_to_boto3_tag_list(module.params['tags']) + + if module.params.get('administration_role_arn'): + # TODO loosen the semantics here to autodetect the account ID and build the ARN + stack_params['AdministrationRoleARN'] = module.params['administration_role_arn'] + if module.params.get('execution_role_name'): + stack_params['ExecutionRoleName'] = module.params['execution_role_name'] + + result = {} + + if module.check_mode: + if state == 'absent' and existing_stack_set: + module.exit_json(changed=True, msg='Stack set would be deleted', meta=[]) + elif state == 'absent' and not existing_stack_set: + module.exit_json(changed=False, msg='Stack set doesn\'t exist', meta=[]) + elif state == 'present' and not existing_stack_set: + module.exit_json(changed=True, msg='New stack set would be created', meta=[]) + elif state == 'present' and existing_stack_set: + new_stacks, existing_stacks, unspecified_stacks = compare_stack_instances( + cfn, + module.params['name'], + module.params['accounts'], + module.params['regions'], + ) + if new_stacks: + module.exit_json(changed=True, msg='New stack instance(s) would be created', meta=[]) + elif unspecified_stacks and module.params.get('purge_stack_instances'): + module.exit_json(changed=True, msg='Old stack instance(s) would be deleted', meta=[]) + else: + # TODO: need to check the template and other settings for correct check mode + module.exit_json(changed=False, msg='No changes detected', meta=[]) + + changed = False + if state == 'present': + if not existing_stack_set: + # on create this parameter has a different name, and cannot be referenced later in the job log + stack_params['ClientRequestToken'] = 'Ansible-StackSet-Create-{0}'.format(operation_uuid) + changed = True + create_stack_set(module, stack_params, cfn) + else: + stack_params['OperationId'] = 'Ansible-StackSet-Update-{0}'.format(operation_uuid) + operation_ids.append(stack_params['OperationId']) + if module.params.get('regions'): + stack_params['OperationPreferences'] = get_operation_preferences(module) + changed |= update_stack_set(module, stack_params, cfn) + + # now create/update any appropriate stack instances + new_stack_instances, existing_stack_instances, unspecified_stack_instances = compare_stack_instances( + cfn, + module.params['name'], + module.params['accounts'], + module.params['regions'], + ) + if new_stack_instances: + operation_ids.append('Ansible-StackInstance-Create-{0}'.format(operation_uuid)) + changed = True + cfn.create_stack_instances( + StackSetName=module.params['name'], + Accounts=list(set(acct for acct, region in new_stack_instances)), + Regions=list(set(region for acct, region in new_stack_instances)), + OperationPreferences=get_operation_preferences(module), + OperationId=operation_ids[-1], + ) + else: + operation_ids.append('Ansible-StackInstance-Update-{0}'.format(operation_uuid)) + cfn.update_stack_instances( + StackSetName=module.params['name'], + Accounts=list(set(acct for acct, region in existing_stack_instances)), + Regions=list(set(region for acct, region in existing_stack_instances)), + OperationPreferences=get_operation_preferences(module), + OperationId=operation_ids[-1], + ) + for op in operation_ids: + await_stack_set_operation( + module, cfn, operation_id=op, + stack_set_name=module.params['name'], + max_wait=module.params.get('wait_timeout'), + ) + + elif state == 'absent': + if not existing_stack_set: + module.exit_json(msg='Stack set {0} does not exist'.format(module.params['name'])) + if module.params.get('purge_stack_instances') is False: + pass + try: + cfn.delete_stack_set( + StackSetName=module.params['name'], + ) + module.exit_json(msg='Stack set {0} deleted'.format(module.params['name'])) + except is_boto3_error_code('OperationInProgressException') as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg='Cannot delete stack {0} while there is an operation in progress'.format(module.params['name'])) + except is_boto3_error_code('StackSetNotEmptyException'): # pylint: disable=duplicate-except + delete_instances_op = 'Ansible-StackInstance-Delete-{0}'.format(operation_uuid) + cfn.delete_stack_instances( + StackSetName=module.params['name'], + Accounts=module.params['accounts'], + Regions=module.params['regions'], + RetainStacks=(not module.params.get('purge_stacks')), + OperationId=delete_instances_op + ) + await_stack_set_operation( + module, cfn, operation_id=delete_instances_op, + stack_set_name=stack_params['StackSetName'], + max_wait=module.params.get('wait_timeout'), + ) + try: + cfn.delete_stack_set( + StackSetName=module.params['name'], + ) + except is_boto3_error_code('StackSetNotEmptyException') as exc: # pylint: disable=duplicate-except + # this time, it is likely that either the delete failed or there are more stacks. + instances = cfn.list_stack_instances( + StackSetName=module.params['name'], + ) + stack_states = ', '.join('(account={Account}, region={Region}, state={Status})'.format(**i) for i in instances['Summaries']) + module.fail_json_aws(exc, msg='Could not purge all stacks, or not all accounts/regions were chosen for deletion: ' + stack_states) + module.exit_json(changed=True, msg='Stack set {0} deleted'.format(module.params['name'])) + + result.update(**describe_stack_tree(module, stack_params['StackSetName'], operation_ids=operation_ids)) + if any(o['status'] == 'FAILED' for o in result['operations']): + module.fail_json(msg="One or more operations failed to execute", **result) + module.exit_json(changed=changed, **result) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/cloudformation_stack_set/aliases b/test/integration/targets/cloudformation_stack_set/aliases new file mode 100644 index 00000000000..56927195182 --- /dev/null +++ b/test/integration/targets/cloudformation_stack_set/aliases @@ -0,0 +1,2 @@ +cloud/aws +unsupported diff --git a/test/integration/targets/cloudformation_stack_set/files/test_bucket_stack.yml b/test/integration/targets/cloudformation_stack_set/files/test_bucket_stack.yml new file mode 100644 index 00000000000..dfbc5224199 --- /dev/null +++ b/test/integration/targets/cloudformation_stack_set/files/test_bucket_stack.yml @@ -0,0 +1,6 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: {} +Resources: + Bukkit: + Type: "AWS::S3::Bucket" + Properties: {} diff --git a/test/integration/targets/cloudformation_stack_set/files/test_modded_bucket_stack.yml b/test/integration/targets/cloudformation_stack_set/files/test_modded_bucket_stack.yml new file mode 100644 index 00000000000..68df61c6178 --- /dev/null +++ b/test/integration/targets/cloudformation_stack_set/files/test_modded_bucket_stack.yml @@ -0,0 +1,9 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: {} +Resources: + Bukkit: + Type: "AWS::S3::Bucket" + Properties: {} + other: + Type: "AWS::SNS::Topic" + Properties: {} diff --git a/test/integration/targets/cloudformation_stack_set/playbooks/full_test.yml b/test/integration/targets/cloudformation_stack_set/playbooks/full_test.yml new file mode 100644 index 00000000000..46b314caed6 --- /dev/null +++ b/test/integration/targets/cloudformation_stack_set/playbooks/full_test.yml @@ -0,0 +1,5 @@ +- hosts: localhost + connection: local + + roles: + - ../../cloudformation_stack_set diff --git a/test/integration/targets/cloudformation_stack_set/runme.sh b/test/integration/targets/cloudformation_stack_set/runme.sh new file mode 100755 index 00000000000..7223f5a94d8 --- /dev/null +++ b/test/integration/targets/cloudformation_stack_set/runme.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# We don't set -u here, due to pypa/virtualenv#150 +set -ex + +MYTMPDIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir') + +trap 'rm -rf "${MYTMPDIR}"' EXIT + +# This is needed for the ubuntu1604py3 tests +# Ubuntu patches virtualenv to make the default python2 +# but for the python3 tests we need virtualenv to use python3 +PYTHON=${ANSIBLE_TEST_PYTHON_INTERPRETER:-python} + +# Run full test suite +virtualenv --system-site-packages --python "${PYTHON}" "${MYTMPDIR}/botocore-recent" +source "${MYTMPDIR}/botocore-recent/bin/activate" +$PYTHON -m pip install 'botocore>1.10.26' boto3 +ansible-playbook -i ../../inventory -e @../../integration_config.yml -e @../../cloud-config-aws.yml -v playbooks/full_test.yml "$@" diff --git a/test/integration/targets/cloudformation_stack_set/tasks/main.yml b/test/integration/targets/cloudformation_stack_set/tasks/main.yml new file mode 100644 index 00000000000..ccc5dd3fcd2 --- /dev/null +++ b/test/integration/targets/cloudformation_stack_set/tasks/main.yml @@ -0,0 +1,186 @@ +--- +# tasks file for cloudformation_stack_set module tests +# These tests require access to two separate AWS accounts + +- name: set up aws connection info + set_fact: + aws_connection_info: &aws_connection_info + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token }}" + region: "{{ aws_region }}" + aws_secondary_connection_info: &aws_secondary_connection_info + aws_access_key: "{{ secondary_aws_access_key }}" + aws_secret_key: "{{ secondary_aws_secret_key }}" + security_token: "{{ secondary_security_token }}" + region: "{{ aws_region }}" + no_log: yes + +- block: + - name: Get current account ID + aws_caller_facts: + <<: *aws_connection_info + register: whoami + - name: Get current account ID + aws_caller_facts: + <<: *aws_secondary_connection_info + register: target_acct + + - name: Policy to allow assuming stackset execution role + iam_managed_policy: + policy_name: AssumeCfnStackSetExecRole + state: present + <<: *aws_connection_info + policy: + Version: '2012-10-17' + Statement: + - Action: 'sts:AssumeRole' + Effect: Allow + Resource: arn:aws:iam::*:role/CfnStackSetExecRole + policy_description: Assume CfnStackSetExecRole + + - name: Create an execution role for us to use + iam_role: + name: CfnStackSetExecRole + <<: *aws_secondary_connection_info + assume_role_policy_document: + Version: '2012-10-17' + Statement: + - Action: 'sts:AssumeRole' + Effect: Allow + Principal: + AWS: '{{ whoami.account }}' + managed_policy: + - arn:aws:iam::aws:policy/PowerUserAccess + + - name: Create an administration role for us to use + iam_role: + name: CfnStackSetAdminRole + <<: *aws_connection_info + assume_role_policy_document: + Version: '2012-10-17' + Statement: + - Action: 'sts:AssumeRole' + Effect: Allow + Principal: + Service: 'cloudformation.amazonaws.com' + managed_policy: + - arn:aws:iam::{{ whoami.account }}:policy/AssumeCfnStackSetExecRole + #- arn:aws:iam::aws:policy/PowerUserAccess + + - name: Should fail without account/regions + cloudformation_stack_set: + <<: *aws_connection_info + name: TestSetOne + description: TestStack Prime + tags: + Some: Thing + Type: Test + wait: true + template: test_bucket_stack.yml + register: result + ignore_errors: true + - name: assert that running with no account fails + assert: + that: + - result is failed + - > + "Can't create a stack set without choosing at least one account" in result.msg + - name: Should fail without roles + cloudformation_stack_set: + <<: *aws_connection_info + name: TestSetOne + description: TestStack Prime + tags: + Some: Thing + Type: Test + wait: true + regions: + - '{{ aws_region }}' + accounts: + - '{{ whoami.account }}' + template_body: '{{ lookup("file", "test_bucket_stack.yml") }}' + register: result + ignore_errors: true + - name: assert that running with no account fails + assert: + that: + - result is failed + + - name: Create an execution role for us to use + iam_role: + name: CfnStackSetExecRole + state: absent + <<: *aws_connection_info + assume_role_policy_document: + Version: '2012-10-17' + Statement: + - Action: 'sts:AssumeRole' + Effect: Allow + Principal: + AWS: arn:aws:iam::{{ whoami.account }}:root + managed_policy: + - arn:aws:iam::aws:policy/PowerUserAccess + + - name: Create stack with roles + cloudformation_stack_set: + <<: *aws_connection_info + name: TestSetTwo + description: TestStack Dos + tags: + Some: Thing + Type: Test + wait: true + regions: + - '{{ aws_region }}' + accounts: + - '{{ target_acct.account }}' + exec_role_name: CfnStackSetExecRole + admin_role_arn: arn:aws:iam::{{ whoami.account }}:role/CfnStackSetAdminRole + template_body: '{{ lookup("file", "test_bucket_stack.yml") }}' + register: result + + - name: Update stack with roles + cloudformation_stack_set: + <<: *aws_connection_info + name: TestSetTwo + description: TestStack Dos + tags: + Some: Thing + Type: Test + wait: true + regions: + - '{{ aws_region }}' + accounts: + - '{{ target_acct.account }}' + exec_role_name: CfnStackSetExecRole + admin_role_arn: arn:aws:iam::{{ whoami.account }}:role/CfnStackSetAdminRole + template_body: '{{ lookup("file", "test_modded_bucket_stack.yml") }}' + always: + - name: Clean up stack one + cloudformation_stack_set: + <<: *aws_connection_info + name: TestSetOne + wait: true + regions: + - '{{ aws_region }}' + accounts: + - '{{ whoami.account }}' + purge_stacks: true + state: absent + - name: Clean up stack two + cloudformation_stack_set: + <<: *aws_connection_info + name: TestSetTwo + description: TestStack Dos + purge_stacks: true + tags: + Some: Thing + Type: Test + wait: true + regions: + - '{{ aws_region }}' + accounts: + - '{{ target_acct.account }}' + template_body: '{{ lookup("file", "test_bucket_stack.yml") }}' + state: absent