[cloud] Add check mode for cloudformation module (#23483)

* cloudformation: dummy check mode

* cloudformation: use changesets to implement check mode

* cloudformation: wait at most 5min for change set

* cloudformation: handle stack creation and deletion in check mode

* cloudformation: standardize output format in check mode

msg is a string, meta is a list

* cloudformation: use same naming convention in get_changeset as create_changeset

also add comment about code duplication between said functions

* Remove unused imports

* PEP8 whitespace fix

* Fix CI, convert success=True check to for/else
pull/27777/merge
Joel Kaasinen 7 years ago committed by Ryan Brown
parent cf1a9d4d22
commit fc7301671e

@ -250,7 +250,6 @@ from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_bytes from ansible.module_utils._text import to_bytes
def get_stack_events(cfn, stack_name): def get_stack_events(cfn, stack_name):
'''This event data was never correct, it worked as a side effect. So the v2.3 format is different.''' '''This event data was never correct, it worked as a side effect. So the v2.3 format is different.'''
ret = {'events':[], 'log':[]} ret = {'events':[], 'log':[]}
@ -297,36 +296,28 @@ def create_stack(module, stack_params, cfn):
def list_changesets(cfn, stack_name): def list_changesets(cfn, stack_name):
res = cfn.list_change_sets(StackName=stack_name) res = cfn.list_change_sets(StackName=stack_name)
changesets = [] return [cs['ChangeSetName'] for cs in res['Summaries']]
for cs in res['Summaries']:
changesets.append(cs['ChangeSetName'])
return changesets
def create_changeset(module, stack_params, cfn): def create_changeset(module, stack_params, cfn):
if 'TemplateBody' not in stack_params and 'TemplateURL' not in stack_params: if 'TemplateBody' not in stack_params and 'TemplateURL' not in stack_params:
module.fail_json(msg="Either 'template' or 'template_url' is required.") module.fail_json(msg="Either 'template' or 'template_url' is required.")
try: try:
if not 'ChangeSetName' in stack_params: changeset_name = build_changeset_name(stack_params)
# Determine ChangeSetName using hash of parameters.
json_params = json.dumps(stack_params, sort_keys=True)
changeset_name = 'Ansible-' + stack_params['StackName'] + '-' + sha1(to_bytes(json_params, errors='surrogate_or_strict')).hexdigest()
stack_params['ChangeSetName'] = changeset_name stack_params['ChangeSetName'] = changeset_name
else:
changeset_name = stack_params['ChangeSetName']
# Determine if this changeset already exists # Determine if this changeset already exists
pending_changesets = list_changesets(cfn, stack_params['StackName']) pending_changesets = list_changesets(cfn, stack_params['StackName'])
if changeset_name in pending_changesets: if changeset_name in pending_changesets:
warning = 'WARNING: '+str(len(pending_changesets))+' pending changeset(s) exist(s) for this stack!' warning = 'WARNING: %d pending changeset(s) exist(s) for this stack!' % len(pending_changesets)
result = dict(changed=False, output='ChangeSet ' + changeset_name + ' already exists.', warnings=[warning]) result = dict(changed=False, output='ChangeSet %s already exists.' % changeset_name, warnings=[warning])
else: else:
cs = cfn.create_change_set(**stack_params) cs = cfn.create_change_set(**stack_params)
result = stack_operation(cfn, stack_params['StackName'], 'UPDATE') result = stack_operation(cfn, stack_params['StackName'], 'UPDATE')
result['warnings'] = [('Created changeset named ' + changeset_name + ' for stack ' + stack_params['StackName']), result['warnings'] = ['Created changeset named %s for stack %s' % (changeset_name, stack_params['StackName']),
('You can execute it using: aws cloudformation execute-change-set --change-set-name ' + cs['Id']), 'You can execute it using: aws cloudformation execute-change-set --change-set-name %s' % cs['Id'],
('NOTE that dependencies on this stack might fail due to pending changes!')] 'NOTE that dependencies on this stack might fail due to pending changes!']
except Exception as err: except Exception as err:
error_msg = boto_exception(err) error_msg = boto_exception(err)
if 'No updates are to be performed.' in error_msg: if 'No updates are to be performed.' in error_msg:
@ -407,11 +398,49 @@ def stack_operation(cfn, stack_name, operation):
return {'failed': True, 'output':'Failed for unknown reasons.'} return {'failed': True, 'output':'Failed for unknown reasons.'}
def build_changeset_name(stack_params):
if 'ChangeSetName' in stack_params:
return stack_params['ChangeSetName']
json_params = json.dumps(stack_params, sort_keys=True)
return 'Ansible-{0}-{1}'.format(
stack_params['StackName'],
sha1(to_bytes(json_params, errors='surrogate_or_strict')).hexdigest()
)
def check_mode_changeset(module, stack_params, cfn):
"""Create a change set, describe it and delete it before returning check mode outputs."""
stack_params['ChangeSetName'] = build_changeset_name(stack_params)
try:
change_set = cfn.create_change_set(**stack_params)
for i in range(60): # total time 5 min
description = cfn.describe_change_set(ChangeSetName=change_set['Id'])
if description['Status'] in ('CREATE_COMPLETE', 'FAILED'):
break
time.sleep(5)
else:
# if the changeset doesn't finish in 5 mins, this `else` will trigger and fail
module.fail_json(msg="Failed to create change set %s" % stack_params['ChangeSetName'])
cfn.delete_change_set(ChangeSetName=change_set['Id'])
reason = description.get('StatusReason')
if description['Status'] == 'FAILED' and "didn't contain changes" in description['StatusReason']:
return {'changed': False, 'msg': reason, 'meta': description['StatusReason']}
return {'changed': True, 'msg': reason, 'meta': description['Changes']}
except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
error_msg = boto_exception(err)
module.fail_json(msg=error_msg, exception=traceback.format_exc())
def get_stack_facts(cfn, stack_name): def get_stack_facts(cfn, stack_name):
try: try:
stack_response = cfn.describe_stacks(StackName=stack_name) stack_response = cfn.describe_stacks(StackName=stack_name)
stack_info = stack_response['Stacks'][0] stack_info = stack_response['Stacks'][0]
#except AmazonCloudFormationException as e:
except (botocore.exceptions.ValidationError,botocore.exceptions.ClientError) as err: except (botocore.exceptions.ValidationError,botocore.exceptions.ClientError) as err:
error_msg = boto_exception(err) error_msg = boto_exception(err)
if 'does not exist' in error_msg: if 'does not exist' in error_msg:
@ -451,6 +480,7 @@ def main():
module = AnsibleModule( module = AnsibleModule(
argument_spec=argument_spec, argument_spec=argument_spec,
mutually_exclusive=[['template_url', 'template']], mutually_exclusive=[['template_url', 'template']],
supports_check_mode=True
) )
if not HAS_BOTO3: if not HAS_BOTO3:
module.fail_json(msg='boto3 and botocore are required for this module') module.fail_json(msg='boto3 and botocore are required for this module')
@ -509,6 +539,16 @@ def main():
stack_info = get_stack_facts(cfn, stack_params['StackName']) stack_info = get_stack_facts(cfn, stack_params['StackName'])
if module.check_mode:
if state == 'absent' and stack_info:
module.exit_json(changed=True, msg='Stack would be deleted', meta=[])
elif state == 'absent' and not stack_info:
module.exit_json(changed=False, msg='Stack doesn\'t exist', meta=[])
elif state == 'present' and not stack_info:
module.exit_json(changed=True, msg='New stack would be created', meta=[])
else:
module.exit_json(**check_mode_changeset(module, stack_params, cfn))
if state == 'present': if state == 'present':
if not stack_info: if not stack_info:
result = create_stack(module, stack_params, cfn) result = create_stack(module, stack_params, cfn)

Loading…
Cancel
Save