diff --git a/stacker/commands/stacker/__init__.py b/stacker/commands/stacker/__init__.py index 4be7bcedc..7bf3661d7 100644 --- a/stacker/commands/stacker/__init__.py +++ b/stacker/commands/stacker/__init__.py @@ -23,10 +23,12 @@ class Stacker(BaseCommand): def configure(self, options, **kwargs): super(Stacker, self).configure(options, **kwargs) - imode = getattr(options, 'interactive', False) - if imode: + if options.interactive: logger.info('Using Interactive AWS Provider') - options.provider = interactive.Provider(region=options.region) + options.provider = interactive.Provider( + region=options.region, + replacements_only=options.replacements_only, + ) else: logger.info('Using Default AWS Provider') options.provider = default.Provider(region=options.region) diff --git a/stacker/commands/stacker/base.py b/stacker/commands/stacker/base.py index 66ccce77e..b2a9a0c22 100644 --- a/stacker/commands/stacker/base.py +++ b/stacker/commands/stacker/base.py @@ -168,10 +168,14 @@ def add_arguments(self, parser): parser.add_argument("config", type=argparse.FileType(), help="The config file where stack configuration " "is located. Must be in yaml format.") - parser.add_argument("-i", "--interactive", action='store_true', + parser.add_argument("-i", "--interactive", action="store_true", help="Enable interactive mode. If specified, this " "will use the AWS interactive provider, which " "leverages Cloudformation Change Sets to display " "changes before running cloudformation templates. " "You'll be asked if you want to execute each change " - "set.") + "set. If you only want to authorize replacements, " + "run with \"--replacements-only\" as well.") + parser.add_argument("--replacements-only", action="store_true", + help="If interactive mode is enabled, stacker will " + "only prompt to authorize replacements.") diff --git a/stacker/providers/aws/interactive.py b/stacker/providers/aws/interactive.py index 033d3ac0f..ca3ba96b8 100644 --- a/stacker/providers/aws/interactive.py +++ b/stacker/providers/aws/interactive.py @@ -24,9 +24,92 @@ def get_change_set_name(): return 'change-set-{}'.format(int(time.time())) +def requires_replacement(changeset): + """Return the changes within the changeset that require replacement. + + Args: + changeset (list): List of changes + + Returns: + list: A list of changes that require replacement, if any. + + """ + return [r for r in changeset if r["ResourceChange"]["Replacement"] == + 'True'] + + +def ask_for_approval(full_changeset=None, include_verbose=False): + """Prompt the user for approval to execute a change set. + + Args: + full_changeset (Optional[list]): A list of the full changeset that will + be output if the user specifies verbose. + include_verbose (Optional[bool]): Boolean for whether or not to include + the verbose option + + """ + approval_options = ['y', 'n'] + if include_verbose: + approval_options.append('v') + + approve = raw_input("Execute the above changes? [{}] ".format( + '/'.join(approval_options))) + + if include_verbose and approve == "v": + logger.info( + "Full changeset:\n%s", + yaml.safe_dump(full_changeset), + ) + return ask_for_approval() + elif approve != "y": + raise exceptions.CancelExecution + + +def output_summary(fqn, action, changeset, replacements_only=False): + """Log a summary of the changeset. + + Args: + fqn (string): fully qualified name of the stack + action (string): action to include in the log message + changeset (list): AWS changeset + replacements_only (Optional[bool]): boolean for whether or not we only + want to list replacements + + """ + replacements = [] + changes = [] + for change in changeset: + resource = change['ResourceChange'] + replacement = resource['Replacement'] == 'True' + summary = '- %s %s (%s)' % ( + resource['Action'], + resource['LogicalResourceId'], + resource['ResourceType'], + ) + if replacement: + replacements.append(summary) + else: + changes.append(summary) + + summary = '' + if replacements: + if not replacements_only: + summary += 'Replacements:\n' + summary += '\n'.join(replacements) + if changes: + if summary: + summary += '\n' + summary += 'Changes:\n%s' % ('\n'.join(changes)) + logger.info('%s %s:\n%s', fqn, action, summary) + + class Provider(AWSProvider): """AWS Cloudformation Change Set Provider""" + def __init__(self, *args, **kwargs): + self.replacements_only = kwargs.pop('replacements_only', False) + super(Provider, self).__init__(*args, **kwargs) + def _wait_till_change_set_complete(self, change_set_id): complete = False response = None @@ -71,14 +154,18 @@ def update_stack(self, fqn, template_url, parameters, tags, **kwargs): if response["ExecutionStatus"] != "AVAILABLE": raise Exception("Unable to execute change set: {}".format(response)) - message = ( - "Cloudformation wants to make the following changes to stack: " - "%s\n%s" - ) - logger.info(message, fqn, yaml.safe_dump(response["Changes"])) - approve = raw_input("Execute the above changes? [y/n] ") - if approve != "y": - raise exceptions.CancelExecution + action = "replacements" if self.replacements_only else "changes" + changeset = response["Changes"] + if self.replacements_only: + changeset = requires_replacement(changeset) + + if len(changeset): + output_summary(fqn, action, changeset, + replacements_only=self.replacements_only) + ask_for_approval( + full_changeset=response["Changes"], + include_verbose=True, + ) retry_on_throttling( self.cloudformation.execute_change_set, diff --git a/stacker/tests/providers/__init__.py b/stacker/tests/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/stacker/tests/providers/aws/__init__.py b/stacker/tests/providers/aws/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/stacker/tests/providers/aws/test_interactive.py b/stacker/tests/providers/aws/test_interactive.py new file mode 100644 index 000000000..d3d87b798 --- /dev/null +++ b/stacker/tests/providers/aws/test_interactive.py @@ -0,0 +1,32 @@ +import unittest +from ....providers.aws.interactive import requires_replacement + + +def generate_resource_change(replacement=True): + resource_change = { + "Action": "Modify", + "Details": [], + "LogicalResourceId": "Fake", + "PhysicalResourceId": "arn:aws:fake", + "Replacement": "True" if replacement else "False", + "ResourceType": "AWS::Fake", + "Scope": ["Properties"], + } + return { + "ResourceChange": resource_change, + "Type": "Resource", + } + + +class TestInteractiveProvider(unittest.TestCase): + + def test_requires_replacement(self): + changeset = [ + generate_resource_change(), + generate_resource_change(replacement=False), + generate_resource_change(), + ] + replacement = requires_replacement(changeset) + self.assertEqual(len(replacement), 2) + for resource in replacement: + self.assertEqual(resource["ResourceChange"]["Replacement"], "True")