Repository: remind101/stacker
Pull request #182 (2016-08-02 22:00:00)

Use more condensed output
This also adds a `--replacements-only` flag which will only prompt for approval when the resource requires replacement


Patch:
diff --git a/docs/config.rst b/docs/config.rst
index 8ae8603..5b25595 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -40,15 +40,6 @@ updating your stacks. By default it uses a bucket named
 If you want to change this, provide the **stacker_bucket** top level key word
 in the config.
 
-Module Paths
-----------------
-When setting the ``classpath`` for blueprints/hooks, it is sometimes desirable to
-load modules from outside the default ``sys.path`` (e.g., to include modules
-inside the same repo as config files).
-
-Adding a path (e.g. ``./``) to the **sys_path** top level key word will allow
-modules from that path location to be used.
-
 Pre & Post Hooks
 ----------------
 
diff --git a/stacker/actions/base.py b/stacker/actions/base.py
index 118906f..dceacfd 100644
--- a/stacker/actions/base.py
+++ b/stacker/actions/base.py
@@ -1,6 +1,5 @@
 import copy
 import logging
-import sys
 
 import boto3
 import botocore.exceptions
@@ -119,8 +118,6 @@ class BaseAction(object):
         return template_url
 
     def execute(self, *args, **kwargs):
-        if "sys_path" in self.context.config:
-            sys.path.append(self.context.config["sys_path"])
         self.pre_run(*args, **kwargs)
         self.run(*args, **kwargs)
         self.post_run(*args, **kwargs)
diff --git a/stacker/commands/stacker/__init__.py b/stacker/commands/stacker/__init__.py
index 4be7bce..7bf3661 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 66ccce7..b2a9a0c 100644
--- a/stacker/commands/stacker/base.py
+++ b/stacker/commands/stacker/base.py
@@ -168,10 +168,14 @@ class BaseCommand(object):
         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/hooks/keypair.py b/stacker/hooks/keypair.py
index fb12d37..ac8cbbd 100644
--- a/stacker/hooks/keypair.py
+++ b/stacker/hooks/keypair.py
@@ -1,29 +1,20 @@
 import logging
 import os
 
-import boto3
+from boto.ec2 import connect_to_region
 
 from . import utils
 
 logger = logging.getLogger(__name__)
 
-def find(lst, key, value):
-    for i, dic in enumerate(lst):
-        if dic[key] == value:
-            return lst[i]
-    return False
 
 def ensure_keypair_exists(region, namespace, mappings, parameters, **kwargs):
-    client = boto3.client('ec2', region_name=region)
+    connection = connect_to_region(region)
     keypair_name = kwargs.get("keypair", parameters.get("SshKeyName"))
-    resp = client.describe_key_pairs()
-    keypair = find(resp['KeyPairs'], 'KeyName', keypair_name)
+    keypair = connection.get_key_pair(keypair_name)
     message = "keypair: %s (%s) %s"
     if keypair:
-        logger.info(message,
-                    keypair["KeyName"],
-                    keypair["KeyFingerprint"],
-                    "exists")
+        logger.info(message, keypair.name, keypair.fingerprint, "exists")
         return True
 
     logger.info("keypair: \"%s\" not found", keypair_name)
@@ -42,12 +33,8 @@ def ensure_keypair_exists(region, namespace, mappings, parameters, **kwargs):
         with open(full_path) as read_file:
             contents = read_file.read()
 
-        keypair = client.import_key_pair(KeyName=keypair_name,
-                                         PublicKeyMaterial=contents)
-        logger.info(message,
-                    keypair["KeyName"],
-                    keypair["KeyFingerprint"],
-                    "imported")
+        keypair = connection.import_key_pair(keypair_name, contents)
+        logger.info(message, keypair.name, keypair.fingerprint, "imported")
         return True
     elif create_or_upload == "create":
         path = raw_input("directory to save keyfile: ")
@@ -56,23 +43,9 @@ def ensure_keypair_exists(region, namespace, mappings, parameters, **kwargs):
             logger.error("\"%s\" is not a valid directory", full_path)
             return False
 
-        file_name = "{0}.pem".format(keypair_name)
-        if os.path.isfile(os.path.join(full_path, file_name)):
-            # This mimics the old boto2 keypair.save error
-            logger.error("\"%s\" already exists in \"%s\" directory",
-                         file_name,
-                         full_path)
-            return False
-
-        keypair = client.create_key_pair(KeyName=keypair_name)
-        logger.info(message,
-                    keypair["KeyName"],
-                    keypair["KeyFingerprint"],
-                    "created")
-        f = open(os.path.join(full_path, file_name), "w")
-        f.write(keypair["KeyMaterial"])
-        f.close()
-        return True
+        keypair = connection.create_key_pair(keypair_name)
+        logger.info(message, keypair.name, keypair.fingerprint, "created")
+        return keypair.save(full_path)
     else:
         logger.warning("no action to find keypair, failing")
         return False
diff --git a/stacker/providers/aws/interactive.py b/stacker/providers/aws/interactive.py
index 033d3ac..ca3ba96 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 @@ class Provider(AWSProvider):
         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,


Test patch:
diff --git a/stacker/tests/fixtures/vpc-bastion-db-web.yaml b/stacker/tests/fixtures/vpc-bastion-db-web.yaml
index 941a8b6..7d6b572 100644
--- a/stacker/tests/fixtures/vpc-bastion-db-web.yaml
+++ b/stacker/tests/fixtures/vpc-bastion-db-web.yaml
@@ -5,9 +5,8 @@
 pre_build:
   - path: stacker.hooks.route53.create_domain
     required: true
-    # Additional args can be passed as a dict of key/value pairs
-    # args:
-    #   BaseDomain: foo
+    # Additional args can be passed as a dict of key/value pairs in kwargs
+    # kwargs:
 # post_build:
 
 mappings:
diff --git a/stacker/tests/providers/__init__.py b/stacker/tests/providers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/stacker/tests/providers/aws/__init__.py b/stacker/tests/providers/aws/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/stacker/tests/providers/aws/test_interactive.py b/stacker/tests/providers/aws/test_interactive.py
new file mode 100644
index 0000000..d3d87b7
--- /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")