Repository: remind101/stacker
Pull request #351 (2017-03-20 01:44:17)

Break out interactive provider for more testing
This is in preparation using change sets for diffing.


Patch:
diff --git a/stacker/commands/stacker/base.py b/stacker/commands/stacker/base.py
index 173f2b8..443ab6d 100644
--- a/stacker/commands/stacker/base.py
+++ b/stacker/commands/stacker/base.py
@@ -119,39 +119,39 @@ class BaseCommand(object):
         return {}
 
     def add_arguments(self, parser):
-        parser.add_argument("-e", "--env", dest="cli_envs",
-                            metavar="ENV=VALUE", type=key_value_arg,
-                            action=KeyValueAction, default={},
-                            help="Adds environment key/value pairs from "
-                                 "the command line. Overrides your "
-                                 "environment file settings. Can be specified "
-                                 "more than once.")
-        parser.add_argument("-r", "--region",
-                            help="The AWS region to launch in.")
-        parser.add_argument("-v", "--verbose", action="count", default=0,
-                            help="Increase output verbosity. May be specified "
-                                 "up to twice.")
-        parser.add_argument("environment", type=environment_file,
-                            default={},
-                            help="Path to a simple `key: value` pair "
-                                 "environment file. The values in the "
-                                 "environment file can be used in the stack "
-                                 "config as if it were a string.Template "
-                                 "type: https://docs.python.org/2/library/"
-                                 "string.html#template-strings. Must define "
-                                 "at least a \"namespace\".")
-        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",
-                            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. 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.")
+        parser.add_argument(
+            "-e", "--env", dest="cli_envs", metavar="ENV=VALUE",
+            type=key_value_arg, action=KeyValueAction, default={},
+            help="Adds environment key/value pairs from the command line. "
+                 "Overrides your environment file settings. Can be specified "
+                 "more than once.")
+        parser.add_argument(
+            "-r", "--region",
+            help="The AWS region to launch in.")
+        parser.add_argument(
+            "-v", "--verbose", action="count", default=0,
+            help="Increase output verbosity. May be specified up to twice.")
+        parser.add_argument(
+            "environment", type=environment_file, default={},
+            help="Path to a simple `key: value` pair environment file. The "
+                 "values in the environment file can be used in the stack "
+                 "config as if it were a string.Template type: "
+                 "https://docs.python.org/2/library/"
+                 "string.html#template-strings. Must define at least a "
+                 "\"namespace\".")
+        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",
+            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. 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/exceptions.py b/stacker/exceptions.py
index 2d6feaa..11fee5a 100644
--- a/stacker/exceptions.py
+++ b/stacker/exceptions.py
@@ -151,3 +151,40 @@ class ValidatorError(Exception):
 
     def __str__(self):
         return self.message
+
+
+class ChangesetDidNotStabilize(Exception):
+    def __init__(self, change_set_id):
+        self.id = change_set_id
+        message = "Changeset '%s' did not reach a completed state." % (
+            change_set_id
+        )
+
+        super(ChangesetDidNotStabilize, self).__init__(message)
+
+
+class UnhandledChangeSetStatus(Exception):
+    def __init__(self, stack_name, change_set_id, status, status_reason):
+        self.stack_name = stack_name
+        self.id = change_set_id
+        self.status = status
+        self.status_reason = status_reason
+        message = (
+            "Changeset '%s' on stack '%s' returned an unhandled status "
+            "'%s: %s'." % (change_set_id, stack_name, status,
+                           status_reason)
+        )
+
+        super(UnhandledChangeSetStatus, self).__init__(message)
+
+
+class UnableToExecuteChangeSet(Exception):
+    def __init__(self, stack_name, change_set_id, execution_status):
+        self.stack_name = stack_name
+        self.id = change_set_id
+        self.execution_status = execution_status
+
+        message = ("Changeset '%s' on stack '%s' had bad execution status: "
+                   "%s" % (change_set_id, stack_name, execution_status))
+
+        super(UnableToExecuteChangeSet, self).__init__(message)
diff --git a/stacker/providers/aws/interactive.py b/stacker/providers/aws/interactive.py
index 38f2037..ce17e2d 100644
--- a/stacker/providers/aws/interactive.py
+++ b/stacker/providers/aws/interactive.py
@@ -38,6 +38,11 @@ def requires_replacement(changeset):
             'True']
 
 
+def get_raw_input(message):
+    """ Just a wrapper for raw_input for testing purposes. """
+    return raw_input(message)
+
+
 def ask_for_approval(full_changeset=None, include_verbose=False):
     """Prompt the user for approval to execute a change set.
 
@@ -52,7 +57,7 @@ def ask_for_approval(full_changeset=None, include_verbose=False):
     if include_verbose:
         approval_options.append('v')
 
-    approve = raw_input("Execute the above changes? [{}] ".format(
+    approve = get_raw_input("Execute the above changes? [{}] ".format(
         '/'.join(approval_options)))
 
     if include_verbose and approve == "v":
@@ -103,75 +108,125 @@ def output_summary(fqn, action, changeset, replacements_only=False):
     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(cfn_client, change_set_id, try_count=5,
+                                  sleep_time=.1):
+    """ Checks state of a changeset, returning when it is in a complete state.
 
-    def _wait_till_change_set_complete(self, change_set_id):
-        complete = False
-        response = None
-        while not complete:
-            response = retry_on_throttling(
-                self.cloudformation.describe_change_set,
-                kwargs={
-                    'ChangeSetName': change_set_id,
-                },
-            )
-            complete = response["Status"] in ("FAILED", "CREATE_COMPLETE")
-            if not complete:
-                time.sleep(2)
-        return response
+    Since changesets can take a little bit of time to get into a complete
+    state, we need to poll it until it does so. This will try to get the
+    state 5 times, waiting `sleep_time` seconds between each try. If, after
+    that time, the changeset is not in a complete state it fails.
 
-    def update_stack(self, fqn, template_url, parameters, tags, **kwargs):
-        logger.debug("Attempting to create change set for stack: %s.", fqn)
+    Args:
+        cfn_client (:class:`botocore.client.CloudFormation`): Used to query
+            cloudformation.
+        change_set_id (str): The unique changeset id to wait for.
+        try_count (int): Number of times to try the call.
+        sleep_time (int): Time to sleep between attempts.
+
+    Return:
+        dict: The response from cloudformation for the describe_change_set
+            call.
+    """
+    complete = False
+    response = None
+    for i in range(try_count):
         response = retry_on_throttling(
-            self.cloudformation.create_change_set,
+            cfn_client.describe_change_set,
             kwargs={
-                'StackName': fqn,
-                'TemplateURL': template_url,
-                'Parameters': parameters,
-                'Tags': tags,
-                'Capabilities': ["CAPABILITY_NAMED_IAM"],
-                'ChangeSetName': get_change_set_name(),
+                'ChangeSetName': change_set_id,
             },
         )
-        change_set_id = response["Id"]
-        response = self._wait_till_change_set_complete(change_set_id)
-        if response["Status"] == "FAILED":
-            if "didn't contain changes" in response["StatusReason"]:
-                logger.debug(
-                    "Stack %s did not change, not updating.",
-                    fqn,
-                )
-                raise exceptions.StackDidNotChange
-            raise Exception(
-                "Failed to describe change set: {}".format(response)
+        complete = response["Status"] in ("FAILED", "CREATE_COMPLETE")
+        if complete:
+            break
+        time.sleep(sleep_time)
+    if not complete:
+        raise exceptions.ChangesetDidNotStabilize(change_set_id)
+    return response
+
+
+def create_change_set(cfn_client, fqn, template_url, parameters, tags,
+                      replacements_only=False):
+    logger.debug("Attempting to create change set for stack: %s.", fqn)
+    response = retry_on_throttling(
+        cfn_client.create_change_set,
+        kwargs={
+            'StackName': fqn,
+            'TemplateURL': template_url,
+            'Parameters': parameters,
+            'Tags': tags,
+            'Capabilities': ["CAPABILITY_NAMED_IAM"],
+            'ChangeSetName': get_change_set_name(),
+        },
+    )
+    change_set_id = response["Id"]
+    response = wait_till_change_set_complete(
+        cfn_client, change_set_id, sleep_time=2
+    )
+    status = response["Status"]
+    if status == "FAILED":
+        status_reason = response["StatusReason"]
+        if "didn't contain changes" in response["StatusReason"]:
+            logger.debug(
+                "Stack %s did not change, not updating.",
+                fqn,
             )
+            raise exceptions.StackDidNotChange
+        raise exceptions.UnhandledChangeSetStatus(
+            fqn, change_set_id, status, status_reason
+        )
 
-        if response["ExecutionStatus"] != "AVAILABLE":
-            raise Exception("Unable to execute change set: "
-                            "{}".format(response))
+    execution_status = response["ExecutionStatus"]
+    if execution_status != "AVAILABLE":
+        raise exceptions.UnableToExecuteChangeSet(fqn,
+                                                  change_set_id,
+                                                  execution_status)
+
+    changes = response["Changes"]
+    return changes, change_set_id
+
+
+class Provider(AWSProvider):
+    """AWS Cloudformation Change Set Provider"""
+
+    def __init__(self, region, replacements_only=False, *args, **kwargs):
+        self.replacements_only = replacements_only
+        super(Provider, self).__init__(region=region, *args, **kwargs)
+
+    def update_stack(self, fqn, template_url, parameters, tags, diff=False,
+                     **kwargs):
+        changes, change_set_id = create_change_set(self.cloudformation, fqn,
+                                                   template_url, parameters,
+                                                   tags, **kwargs)
 
         action = "replacements" if self.replacements_only else "changes"
-        changeset = response["Changes"]
+        full_changes = changes
         if self.replacements_only:
-            changeset = requires_replacement(changeset)
+            changes = requires_replacement(changes)
 
-        if len(changeset):
-            output_summary(fqn, action, changeset,
+        if changes:
+            output_summary(fqn, action, changes,
                            replacements_only=self.replacements_only)
-            ask_for_approval(
-                full_changeset=response["Changes"],
-                include_verbose=True,
+            if not diff:
+                ask_for_approval(
+                    full_changes=full_changes,
+                    include_verbose=True,
+                )
+
+        if not diff:
+            retry_on_throttling(
+                self.cloudformation.execute_change_set,
+                kwargs={
+                    'ChangeSetName': change_set_id,
+                },
+            )
+        else:
+            retry_on_throttling(
+                self.cloudformation.delete_change_set,
+                kwargs={
+                    'ChangeSetName': change_set_id,
+                },
             )
 
-        retry_on_throttling(
-            self.cloudformation.execute_change_set,
-            kwargs={
-                'ChangeSetName': change_set_id,
-            },
-        )
         return True


Test patch:
diff --git a/stacker/tests/providers/aws/test_interactive.py b/stacker/tests/providers/aws/test_interactive.py
index d3d87b7..0e7f5cb 100644
--- a/stacker/tests/providers/aws/test_interactive.py
+++ b/stacker/tests/providers/aws/test_interactive.py
@@ -1,5 +1,35 @@
 import unittest
-from ....providers.aws.interactive import requires_replacement
+from datetime import datetime
+import random
+import string
+
+from mock import patch
+from botocore.stub import Stubber
+import boto3
+
+from ....providers.aws.interactive import (
+    Provider,
+    requires_replacement,
+    ask_for_approval,
+    wait_till_change_set_complete,
+    create_change_set,
+)
+
+from .... import exceptions
+
+
+def random_string(length=12):
+    """ Returns a random string of variable length.
+
+    Args:
+        length (int): The # of characters to use in the random string.
+
+    Returns:
+        str: The random string.
+    """
+
+    return ''.join(
+        [random.choice(string.ascii_letters) for _ in range(length)])
 
 
 def generate_resource_change(replacement=True):
@@ -18,7 +48,74 @@ def generate_resource_change(replacement=True):
     }
 
 
-class TestInteractiveProvider(unittest.TestCase):
+def generate_change_set_response(status, execution_status="AVAILABLE",
+                                 changes=[], status_reason="FAKE"):
+    return {
+        "ChangeSetName": "string",
+        "ChangeSetId": "string",
+        "StackId": "string",
+        "StackName": "string",
+        "Description": "string",
+        "Parameters": [
+            {
+                "ParameterKey": "string",
+                "ParameterValue": "string",
+                "UsePreviousValue": False
+            },
+        ],
+        "CreationTime": datetime(2015, 1, 1),
+        "ExecutionStatus": execution_status,
+        "Status": status,
+        "StatusReason": status_reason,
+        "NotificationARNs": [
+            "string",
+        ],
+        "Capabilities": [
+            "CAPABILITY_NAMED_IAM",
+        ],
+        "Tags": [
+            {
+                "Key": "string",
+                "Value": "string"
+            },
+        ],
+        "Changes": changes,
+        "NextToken": "string"
+    }
+
+
+def generate_change(action="Modify", resource_type="EC2::Instance",
+                    replacement="False", requires_recreation="Never"):
+    """ Generate a minimal change for a changeset """
+    return {
+        "Type": "Resource",
+        "ResourceChange": {
+            "Action": action,
+            "LogicalResourceId": random_string(),
+            "PhysicalResourceId": random_string(),
+            "ResourceType": resource_type,
+            "Replacement": replacement,
+            "Scope": ["Properties"],
+            "Details": [
+                {
+                    "Target": {
+                        "Attribute": "Properties",
+                        "Name": random_string(),
+                        "RequiresRecreation": requires_recreation
+                    },
+                    "Evaluation": "Static",
+                    "ChangeSource": "ResourceReference",
+                    "CausingEntity": random_string()
+                },
+            ]
+        }
+    }
+
+
+class TestInteractiveProviderMethods(unittest.TestCase):
+    def setUp(self):
+        self.cfn = boto3.client("cloudformation")
+        self.stubber = Stubber(self.cfn)
 
     def test_requires_replacement(self):
         changeset = [
@@ -30,3 +127,180 @@ class TestInteractiveProvider(unittest.TestCase):
         self.assertEqual(len(replacement), 2)
         for resource in replacement:
             self.assertEqual(resource["ResourceChange"]["Replacement"], "True")
+
+    def test_ask_for_approval(self):
+        get_input_path = "stacker.providers.aws.interactive.get_raw_input"
+        with patch(get_input_path, return_value="y"):
+            self.assertIsNone(ask_for_approval([]))
+
+        for v in ("n", "N", "x", "\n"):
+            with patch(get_input_path, return_value=v):
+                with self.assertRaises(exceptions.CancelExecution):
+                    ask_for_approval([])
+
+        with patch(get_input_path, side_effect=["v", "n"]) as mock_get_input:
+            with patch("yaml.safe_dump") as mock_safe_dump:
+                with self.assertRaises(exceptions.CancelExecution):
+                    ask_for_approval([], True)
+                self.assertEqual(mock_safe_dump.call_count, 1)
+            self.assertEqual(mock_get_input.call_count, 2)
+
+    def test_wait_till_change_set_complete_success(self):
+        self.stubber.add_response(
+            "describe_change_set",
+            generate_change_set_response("CREATE_COMPLETE")
+        )
+        with self.stubber:
+            wait_till_change_set_complete(self.cfn, "FAKEID")
+
+        self.stubber.add_response(
+            "describe_change_set",
+            generate_change_set_response("FAILED")
+        )
+        with self.stubber:
+            wait_till_change_set_complete(self.cfn, "FAKEID")
+
+    def test_wait_till_change_set_complete_failed(self):
+        # Need 2 responses for try_count
+        for i in range(2):
+            self.stubber.add_response(
+                "describe_change_set",
+                generate_change_set_response("CREATE_PENDING")
+            )
+        with self.stubber:
+            with self.assertRaises(exceptions.ChangesetDidNotStabilize):
+                wait_till_change_set_complete(self.cfn, "FAKEID", try_count=2,
+                                              sleep_time=.1)
+
+    def test_create_change_set_stack_did_not_change(self):
+        self.stubber.add_response(
+            "create_change_set",
+            {'Id': 'CHANGESETID', 'StackId': 'STACKID'}
+        )
+
+        self.stubber.add_response(
+            "describe_change_set",
+            generate_change_set_response(
+                "FAILED", status_reason="Stack didn't contain changes."
+            )
+        )
+
+        with self.stubber:
+            with self.assertRaises(exceptions.StackDidNotChange):
+                create_change_set(
+                    cfn_client=self.cfn, fqn="my-fake-stack",
+                    template_url="http://fake.template.url.com/",
+                    parameters=[], tags=[]
+                )
+
+    def test_create_change_set_unhandled_failed_status(self):
+        self.stubber.add_response(
+            "create_change_set",
+            {'Id': 'CHANGESETID', 'StackId': 'STACKID'}
+        )
+
+        self.stubber.add_response(
+            "describe_change_set",
+            generate_change_set_response(
+                "FAILED", status_reason="Some random bad thing."
+            )
+        )
+
+        with self.stubber:
+            with self.assertRaises(exceptions.UnhandledChangeSetStatus):
+                create_change_set(
+                    cfn_client=self.cfn, fqn="my-fake-stack",
+                    template_url="http://fake.template.url.com/",
+                    parameters=[], tags=[]
+                )
+
+    def test_create_change_set_bad_execution_status(self):
+        self.stubber.add_response(
+            "create_change_set",
+            {'Id': 'CHANGESETID', 'StackId': 'STACKID'}
+        )
+
+        self.stubber.add_response(
+            "describe_change_set",
+            generate_change_set_response(
+                status="CREATE_COMPLETE", execution_status="UNAVAILABLE",
+            )
+        )
+
+        with self.stubber:
+            with self.assertRaises(exceptions.UnableToExecuteChangeSet):
+                create_change_set(
+                    cfn_client=self.cfn, fqn="my-fake-stack",
+                    template_url="http://fake.template.url.com/",
+                    parameters=[], tags=[]
+                )
+
+
+class TestInteractiveProvider(unittest.TestCase):
+    def setUp(self):
+        region = "us-east-1"
+        self.provider = Provider(region=region)
+        self.stubber = Stubber(self.provider.cloudformation)
+
+    def test_successful_init(self):
+        region = "us-east-1"
+        replacements = True
+        p = Provider(region=region, replacements_only=replacements)
+        self.assertEqual(p.region, region)
+        self.assertEqual(p.replacements_only, replacements)
+
+    @patch("stacker.providers.aws.interactive.ask_for_approval")
+    def test_update_stack_execute_success(self, patched_approval):
+        self.stubber.add_response(
+            "create_change_set",
+            {'Id': 'CHANGESETID', 'StackId': 'STACKID'}
+        )
+        changes = []
+        changes.append(generate_change())
+
+        self.stubber.add_response(
+            "describe_change_set",
+            generate_change_set_response(
+                status="CREATE_COMPLETE", execution_status="AVAILABLE",
+                changes=changes,
+            )
+        )
+
+        self.stubber.add_response("execute_change_set", {})
+
+        with self.stubber:
+            self.provider.update_stack(
+                fqn="my-fake-stack",
+                template_url="http://fake.template.url.com/",
+                parameters=[], tags=[]
+            )
+
+        self.assertEqual(patched_approval.call_count, 1)
+
+    @patch("stacker.providers.aws.interactive.ask_for_approval")
+    def test_update_stack_diff_success(self, patched_approval):
+        self.stubber.add_response(
+            "create_change_set",
+            {'Id': 'CHANGESETID', 'StackId': 'STACKID'}
+        )
+        changes = []
+        changes.append(generate_change())
+
+        self.stubber.add_response(
+            "describe_change_set",
+            generate_change_set_response(
+                status="CREATE_COMPLETE", execution_status="AVAILABLE",
+                changes=changes,
+            )
+        )
+
+        self.stubber.add_response("delete_change_set", {})
+
+        with self.stubber:
+            self.provider.update_stack(
+                fqn="my-fake-stack",
+                template_url="http://fake.template.url.com/",
+                parameters=[], tags=[], diff=True
+            )
+
+        self.assertEqual(patched_approval.call_count, 0)