Add ChangeSets to cloudformation_info + updates (#64571)
* cloudformation_info updates Add ability to retrieve stack change sets Update module to use AnsibleAWSModule Update backoff to use new decorator style Updated exceptions to use fail_json_aws Converted outputs to snake_case where appropriate, for cloudformation_info only to preserve cloudformation_facts functionality. Unconverted keys are stack_outputs, stack_parameters, stack_policy, stack_resources, stack_tags and stack_template * cloudformation_info updates Add ability to retrieve stack change sets Update module to use AnsibleAWSModule Update backoff to use new decorator style Updated exceptions to use fail_json_aws Converted outputs to snake_case where appropriate, for cloudformation_info only to preserve cloudformation_facts functionality. Unconverted keys are stack_outputs, stack_parameters, stack_policy, stack_resources, stack_tags and stack_template * Adding integration tests * Remove unneeded debug statement * Add myself to Authors * rename stack-name to match iam policy limits aws-terminator permission for cloudformation have the following resource Resource: arn:aws:cloudformation:us-east-1:{{ aws_account_id }}:stack/ansible-test* updating test stack name to match * removing .orig file from rebase * fix stack name, underscore not allowed * rename integration testsuite to cloudformation rename per request * add resource_prefix to resource names * prefix stack name with ansible-test IAM policy in aws-terminator requires the stack name to begin with ansible-test * add single quotes around variable in test * fix test for display name mistakenly updated when changing topic_name to use resource_prefix. The test is to check the display name, which is what the stack update changes * fix value to display name test * rename main.yaml to main.yml before rebase * Merge tests with #65643 remove uneeded files using tests from #65643 with added check to validate changeset is present in info when requested * fix assert on chageset check * remove trailing blank line
This commit is contained in:
parent
ae0df67539
commit
45a413a4a2
2 changed files with 107 additions and 72 deletions
|
@ -23,7 +23,9 @@ requirements:
|
|||
- boto3 >= 1.0.0
|
||||
- python >= 2.6
|
||||
version_added: "2.2"
|
||||
author: Justin Menga (@jmenga)
|
||||
author:
|
||||
- Justin Menga (@jmenga)
|
||||
- Kevin Coming (@waffie1)
|
||||
options:
|
||||
stack_name:
|
||||
description:
|
||||
|
@ -54,6 +56,12 @@ options:
|
|||
- Get stack policy for the stack.
|
||||
type: bool
|
||||
default: false
|
||||
stack_change_sets:
|
||||
description:
|
||||
- Get stack change sets for the stack
|
||||
type: bool
|
||||
default: false
|
||||
version_added: '2.10'
|
||||
extends_documentation_fragment:
|
||||
- aws
|
||||
- ec2
|
||||
|
@ -155,23 +163,25 @@ stack_resources:
|
|||
AutoScalingGroup: "dev-someapp-AutoscalingGroup-1SKEXXBCAN0S7"
|
||||
AutoScalingSecurityGroup: "sg-abcd1234"
|
||||
ApplicationDatabase: "dazvlpr01xj55a"
|
||||
stack_change_sets:
|
||||
description: A list of stack change sets. Each item in the list represents the details of a specific changeset
|
||||
|
||||
returned: only if all_facts or stack_change_sets is true and the stack exists
|
||||
type: list
|
||||
'''
|
||||
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from functools import partial
|
||||
from ansible.module_utils._text import to_native
|
||||
from ansible.module_utils.aws.core import AnsibleAWSModule
|
||||
from ansible.module_utils.ec2 import (camel_dict_to_snake_dict, AWSRetry, boto3_tag_list_to_ansible_dict)
|
||||
|
||||
try:
|
||||
import boto3
|
||||
import botocore
|
||||
HAS_BOTO3 = True
|
||||
except ImportError:
|
||||
HAS_BOTO3 = False
|
||||
|
||||
from ansible.module_utils._text import to_native
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils.ec2 import (get_aws_connection_info, ec2_argument_spec, boto3_conn,
|
||||
camel_dict_to_snake_dict, AWSRetry, boto3_tag_list_to_ansible_dict)
|
||||
pass # handled by AnsibleAWSModule
|
||||
|
||||
|
||||
class CloudFormationServiceManager:
|
||||
|
@ -179,82 +189,94 @@ class CloudFormationServiceManager:
|
|||
|
||||
def __init__(self, module):
|
||||
self.module = module
|
||||
self.client = module.client('cloudformation')
|
||||
|
||||
try:
|
||||
region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True)
|
||||
self.client = boto3_conn(module, conn_type='client',
|
||||
resource='cloudformation', region=region,
|
||||
endpoint=ec2_url, **aws_connect_kwargs)
|
||||
backoff_wrapper = AWSRetry.jittered_backoff(retries=10, delay=3, max_delay=30)
|
||||
self.client.describe_stacks = backoff_wrapper(self.client.describe_stacks)
|
||||
self.client.list_stack_resources = backoff_wrapper(self.client.list_stack_resources)
|
||||
self.client.describe_stack_events = backoff_wrapper(self.client.describe_stack_events)
|
||||
self.client.get_stack_policy = backoff_wrapper(self.client.get_stack_policy)
|
||||
self.client.get_template = backoff_wrapper(self.client.get_template)
|
||||
except botocore.exceptions.NoRegionError:
|
||||
self.module.fail_json(msg="Region must be specified as a parameter, in AWS_DEFAULT_REGION environment variable or in boto configuration file")
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg="Can't establish connection - " + str(e), exception=traceback.format_exc())
|
||||
@AWSRetry.exponential_backoff(retries=5, delay=5)
|
||||
def describe_stacks_with_backoff(self, **kwargs):
|
||||
paginator = self.client.get_paginator('describe_stacks')
|
||||
return paginator.paginate(**kwargs).build_full_result()['Stacks']
|
||||
|
||||
def describe_stacks(self, stack_name=None):
|
||||
try:
|
||||
kwargs = {'StackName': stack_name} if stack_name else {}
|
||||
func = partial(self.client.describe_stacks, **kwargs)
|
||||
response = self.paginated_response(func, 'Stacks')
|
||||
response = self.describe_stacks_with_backoff(**kwargs)
|
||||
if response is not None:
|
||||
return response
|
||||
self.module.fail_json(msg="Error describing stack(s) - an empty response was returned")
|
||||
except Exception as e:
|
||||
except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
|
||||
if 'does not exist' in e.response['Error']['Message']:
|
||||
# missing stack, don't bail.
|
||||
return {}
|
||||
self.module.fail_json(msg="Error describing stack - " + to_native(e), exception=traceback.format_exc())
|
||||
self.module.fail_json_aws(e, msg="Error describing stack " + stack_name)
|
||||
|
||||
@AWSRetry.exponential_backoff(retries=5, delay=5)
|
||||
def list_stack_resources_with_backoff(self, stack_name):
|
||||
paginator = self.client.get_paginator('list_stack_resources')
|
||||
return paginator.paginate(StackName=stack_name).build_full_result()['StackResourceSummaries']
|
||||
|
||||
def list_stack_resources(self, stack_name):
|
||||
try:
|
||||
func = partial(self.client.list_stack_resources, StackName=stack_name)
|
||||
return self.paginated_response(func, 'StackResourceSummaries')
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg="Error listing stack resources - " + str(e), exception=traceback.format_exc())
|
||||
return self.list_stack_resources_with_backoff(stack_name)
|
||||
except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
|
||||
self.module.fail_json_aws(e, msg="Error listing stack resources for stack " + stack_name)
|
||||
|
||||
@AWSRetry.exponential_backoff(retries=5, delay=5)
|
||||
def describe_stack_events_with_backoff(self, stack_name):
|
||||
paginator = self.client.get_paginator('describe_stack_events')
|
||||
return paginator.paginate(StackName=stack_name).build_full_result()['StackEvents']
|
||||
|
||||
def describe_stack_events(self, stack_name):
|
||||
try:
|
||||
func = partial(self.client.describe_stack_events, StackName=stack_name)
|
||||
return self.paginated_response(func, 'StackEvents')
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg="Error describing stack events - " + str(e), exception=traceback.format_exc())
|
||||
return self.describe_stack_events_with_backoff(stack_name)
|
||||
except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
|
||||
self.module.fail_json_aws(e, msg="Error listing stack events for stack " + stack_name)
|
||||
|
||||
@AWSRetry.exponential_backoff(retries=5, delay=5)
|
||||
def list_stack_change_sets_with_backoff(self, stack_name):
|
||||
paginator = self.client.get_paginator('list_change_sets')
|
||||
return paginator.paginate(StackName=stack_name).build_full_result()['Summaries']
|
||||
|
||||
@AWSRetry.exponential_backoff(retries=5, delay=5)
|
||||
def describe_stack_change_set_with_backoff(self, **kwargs):
|
||||
paginator = self.client.get_paginator('describe_change_set')
|
||||
return paginator.paginate(**kwargs).build_full_result()
|
||||
|
||||
def describe_stack_change_sets(self, stack_name):
|
||||
changes = []
|
||||
try:
|
||||
change_sets = self.list_stack_change_sets_with_backoff(stack_name)
|
||||
for item in change_sets:
|
||||
changes.append(self.describe_stack_change_set_with_backoff(
|
||||
StackName=stack_name,
|
||||
ChangeSetName=item['ChangeSetName']))
|
||||
return changes
|
||||
except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
|
||||
self.module.fail_json_aws(e, msg="Error describing stack change sets for stack " + stack_name)
|
||||
|
||||
@AWSRetry.exponential_backoff(retries=5, delay=5)
|
||||
def get_stack_policy_with_backoff(self, stack_name):
|
||||
return self.client.get_stack_policy(StackName=stack_name)
|
||||
|
||||
def get_stack_policy(self, stack_name):
|
||||
try:
|
||||
response = self.client.get_stack_policy(StackName=stack_name)
|
||||
response = self.get_stack_policy_with_backoff(stack_name)
|
||||
stack_policy = response.get('StackPolicyBody')
|
||||
if stack_policy:
|
||||
return json.loads(stack_policy)
|
||||
return dict()
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg="Error getting stack policy - " + str(e), exception=traceback.format_exc())
|
||||
except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
|
||||
self.module.fail_json_aws(e, msg="Error getting stack policy for stack " + stack_name)
|
||||
|
||||
@AWSRetry.exponential_backoff(retries=5, delay=5)
|
||||
def get_template_with_backoff(self, stack_name):
|
||||
return self.client.get_template(StackName=stack_name)
|
||||
|
||||
def get_template(self, stack_name):
|
||||
try:
|
||||
response = self.client.get_template(StackName=stack_name)
|
||||
response = self.get_template_with_backoff(stack_name)
|
||||
return response.get('TemplateBody')
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg="Error getting stack template - " + str(e), exception=traceback.format_exc())
|
||||
|
||||
def paginated_response(self, func, result_key, next_token=None):
|
||||
'''
|
||||
Returns expanded response for paginated operations.
|
||||
The 'result_key' is used to define the concatenated results that are combined from each paginated response.
|
||||
'''
|
||||
args = dict()
|
||||
if next_token:
|
||||
args['NextToken'] = next_token
|
||||
response = func(**args)
|
||||
result = response.get(result_key)
|
||||
next_token = response.get('NextToken')
|
||||
if not next_token:
|
||||
return result
|
||||
return result + self.paginated_response(func, result_key, next_token)
|
||||
except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
|
||||
self.module.fail_json_aws(e, msg="Error getting stack template for stack " + stack_name)
|
||||
|
||||
|
||||
def to_dict(items, key, value):
|
||||
|
@ -266,25 +288,22 @@ def to_dict(items, key, value):
|
|||
|
||||
|
||||
def main():
|
||||
argument_spec = ec2_argument_spec()
|
||||
argument_spec.update(dict(
|
||||
argument_spec = dict(
|
||||
stack_name=dict(),
|
||||
all_facts=dict(required=False, default=False, type='bool'),
|
||||
stack_policy=dict(required=False, default=False, type='bool'),
|
||||
stack_events=dict(required=False, default=False, type='bool'),
|
||||
stack_resources=dict(required=False, default=False, type='bool'),
|
||||
stack_template=dict(required=False, default=False, type='bool'),
|
||||
))
|
||||
stack_change_sets=dict(required=False, default=False, type='bool'),
|
||||
)
|
||||
module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=False)
|
||||
|
||||
module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=False)
|
||||
is_old_facts = module._name == 'cloudformation_facts'
|
||||
if is_old_facts:
|
||||
module.deprecate("The 'cloudformation_facts' module has been renamed to 'cloudformation_info', "
|
||||
"and the renamed one no longer returns ansible_facts", version='2.13')
|
||||
|
||||
if not HAS_BOTO3:
|
||||
module.fail_json(msg='boto3 is required.')
|
||||
|
||||
service_mgr = CloudFormationServiceManager(module)
|
||||
|
||||
if is_old_facts:
|
||||
|
@ -299,31 +318,36 @@ def main():
|
|||
# Create stack output and stack parameter dictionaries
|
||||
if facts['stack_description']:
|
||||
facts['stack_outputs'] = to_dict(facts['stack_description'].get('Outputs'), 'OutputKey', 'OutputValue')
|
||||
facts['stack_parameters'] = to_dict(facts['stack_description'].get('Parameters'), 'ParameterKey', 'ParameterValue')
|
||||
facts['stack_parameters'] = to_dict(facts['stack_description'].get('Parameters'),
|
||||
'ParameterKey', 'ParameterValue')
|
||||
facts['stack_tags'] = boto3_tag_list_to_ansible_dict(facts['stack_description'].get('Tags'))
|
||||
|
||||
# normalize stack description API output
|
||||
facts['stack_description'] = camel_dict_to_snake_dict(facts['stack_description'])
|
||||
|
||||
# Create optional stack outputs
|
||||
all_facts = module.params.get('all_facts')
|
||||
if all_facts or module.params.get('stack_resources'):
|
||||
facts['stack_resource_list'] = service_mgr.list_stack_resources(stack_name)
|
||||
facts['stack_resources'] = to_dict(facts.get('stack_resource_list'), 'LogicalResourceId', 'PhysicalResourceId')
|
||||
facts['stack_resources'] = to_dict(facts.get('stack_resource_list'),
|
||||
'LogicalResourceId', 'PhysicalResourceId')
|
||||
if all_facts or module.params.get('stack_template'):
|
||||
facts['stack_template'] = service_mgr.get_template(stack_name)
|
||||
if all_facts or module.params.get('stack_policy'):
|
||||
facts['stack_policy'] = service_mgr.get_stack_policy(stack_name)
|
||||
if all_facts or module.params.get('stack_events'):
|
||||
facts['stack_events'] = service_mgr.describe_stack_events(stack_name)
|
||||
if all_facts or module.params.get('stack_change_sets'):
|
||||
facts['stack_change_sets'] = service_mgr.describe_stack_change_sets(stack_name)
|
||||
|
||||
if is_old_facts:
|
||||
result['ansible_facts']['cloudformation'][stack_name] = facts
|
||||
else:
|
||||
result['cloudformation'][stack_name] = facts
|
||||
result['cloudformation'][stack_name] = camel_dict_to_snake_dict(facts, ignore_list=('stack_outputs',
|
||||
'stack_parameters',
|
||||
'stack_policy',
|
||||
'stack_resources',
|
||||
'stack_tags',
|
||||
'stack_template'))
|
||||
|
||||
result['changed'] = False
|
||||
module.exit_json(**result)
|
||||
module.exit_json(changed=False, **result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
@ -193,6 +193,17 @@
|
|||
- "'change_set_id' in create_changeset_result"
|
||||
- "'Stack CREATE_CHANGESET complete' in create_changeset_result.output"
|
||||
|
||||
- name: get stack details with changesets
|
||||
cloudformation_info:
|
||||
stack_name: "{{ stack_name }}"
|
||||
stack_change_sets: True
|
||||
register: stack_info
|
||||
|
||||
- name: assert changesets in info
|
||||
assert:
|
||||
that:
|
||||
- "'stack_change_sets' in stack_info.cloudformation[stack_name]"
|
||||
|
||||
# try to create an empty changeset by passing in unchanged template
|
||||
- name: create a changeset
|
||||
cloudformation:
|
||||
|
|
Loading…
Reference in a new issue