New module cloudformation_stack_set (#41669)

* [AWS] new module cloudformation_stack_set with integration tests
This commit is contained in:
Ryan Brown 2018-08-20 14:38:14 -04:00 committed by Sloane Hertel
parent 121551d442
commit 6d52afeed6
8 changed files with 900 additions and 1 deletions

View file

@ -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 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. # 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): class AnsibleAWSModule(object):

View file

@ -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()

View file

@ -0,0 +1,2 @@
cloud/aws
unsupported

View file

@ -0,0 +1,6 @@
AWSTemplateFormatVersion: "2010-09-09"
Parameters: {}
Resources:
Bukkit:
Type: "AWS::S3::Bucket"
Properties: {}

View file

@ -0,0 +1,9 @@
AWSTemplateFormatVersion: "2010-09-09"
Parameters: {}
Resources:
Bukkit:
Type: "AWS::S3::Bucket"
Properties: {}
other:
Type: "AWS::SNS::Topic"
Properties: {}

View file

@ -0,0 +1,5 @@
- hosts: localhost
connection: local
roles:
- ../../cloudformation_stack_set

View file

@ -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 "$@"

View file

@ -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