mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
New module cloudformation_stack_set (#41669)
* [AWS] new module cloudformation_stack_set with integration tests
This commit is contained in:
parent
121551d442
commit
6d52afeed6
8 changed files with 900 additions and 1 deletions
|
@ -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):
|
||||
|
|
672
lib/ansible/modules/cloud/amazon/cloudformation_stack_set.py
Normal file
672
lib/ansible/modules/cloud/amazon/cloudformation_stack_set.py
Normal 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()
|
|
@ -0,0 +1,2 @@
|
|||
cloud/aws
|
||||
unsupported
|
|
@ -0,0 +1,6 @@
|
|||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Parameters: {}
|
||||
Resources:
|
||||
Bukkit:
|
||||
Type: "AWS::S3::Bucket"
|
||||
Properties: {}
|
|
@ -0,0 +1,9 @@
|
|||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Parameters: {}
|
||||
Resources:
|
||||
Bukkit:
|
||||
Type: "AWS::S3::Bucket"
|
||||
Properties: {}
|
||||
other:
|
||||
Type: "AWS::SNS::Topic"
|
||||
Properties: {}
|
|
@ -0,0 +1,5 @@
|
|||
- hosts: localhost
|
||||
connection: local
|
||||
|
||||
roles:
|
||||
- ../../cloudformation_stack_set
|
19
test/integration/targets/cloudformation_stack_set/runme.sh
Executable file
19
test/integration/targets/cloudformation_stack_set/runme.sh
Executable 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 "$@"
|
186
test/integration/targets/cloudformation_stack_set/tasks/main.yml
Normal file
186
test/integration/targets/cloudformation_stack_set/tasks/main.yml
Normal 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
|
Loading…
Reference in a new issue