Repository: remind101/stacker
Pull request #36 (2015-06-19 19:17:24)

Fix unchanged rollback stack issue
When a stack update fails & rolls back to an old state, it leaves the
stack in ROLLBACK_COMPLETE.  Unfortunately since this isn't recognized
as a 'COMPLETE' status (it's hard to know if it's complete or not) this
left the entire meta-stack in an un-updateable state.

This handles the case where we 'skip' a stack because nothing has
changed a little bit better, and seems to actually speed things up since
we deal with skipped stacks earlier.


Patch:
diff --git a/stacker/builder.py b/stacker/builder.py
index 5659ab9..88e9e8d 100644
--- a/stacker/builder.py
+++ b/stacker/builder.py
@@ -5,7 +5,9 @@ import time
 from aws_helper.connection import ConnectionManager
 from boto.exception import BotoServerError, S3ResponseError
 
-from .plan import Plan, INPROGRESS_STATUSES, STATUS_SUBMITTED, COMPLETE_STATUSES
+from .plan import (
+    Plan, INPROGRESS_STATUSES, SUBMITTED, SKIPPED, PENDING, COMPLETE_STATUSES
+)
 from .util import get_bucket_location, load_object_from_string
 
 logger = logging.getLogger(__name__)
@@ -18,7 +20,8 @@ class MissingParameterException(Exception):
         message = 'Missing required parameters: %s' % (
             ', '.join(parameters),
         )
-        super(MissingParameterException, self).__init__(message, *args, **kwargs)
+        super(MissingParameterException, self).__init__(message, *args,
+                                                        **kwargs)
 
 
 class ParameterDoesNotExist(Exception):
@@ -311,7 +314,7 @@ class Builder(object):
                                               template_url=template_url,
                                               parameters=parameters, tags=tags,
                                               capabilities=['CAPABILITY_IAM'])
-        return True
+        return SUBMITTED
 
     def update_stack(self, full_name, template_url, parameters, tags):
         """ Updates an existing stack in CloudFormation. """
@@ -320,12 +323,12 @@ class Builder(object):
             self.conn.cloudformation.update_stack(
                 full_name, template_url=template_url, parameters=parameters,
                 tags=tags, capabilities=['CAPABILITY_IAM'])
-            return True
+            return SUBMITTED
         except BotoServerError as e:
             if 'No updates are to be performed.' in e.message:
                 logger.info("Stack %s did not change, not updating.",
                             full_name)
-                return True
+                return SKIPPED
             raise
 
     def launch_stack(self, stack_name, blueprint):
@@ -349,16 +352,15 @@ class Builder(object):
         required_params = [k for k, v in blueprint.required_parameters]
         parameters = handle_missing_parameters(parameters, required_params,
                                                stack)
-        submitted = False
+        status = PENDING
         if not stack:
-            submitted = self.create_stack(full_name, template_url, parameters,
-                                          tags)
+            status = self.create_stack(full_name, template_url, parameters,
+                                       tags)
         else:
-            submitted = self.update_stack(full_name, template_url, parameters,
-                                          tags)
+            status = self.update_stack(full_name, template_url, parameters,
+                                       tags)
 
-        if submitted:
-            stack_context.submit()
+        stack_context.set_status(status)
 
     def get_outputs(self, stack_name, force=False):
         """ Gets all the outputs from a given stack in CloudFormation.
@@ -397,7 +399,7 @@ class Builder(object):
             local_status = stack_context.status
             # We only update local status on stacks that have been marked
             # locally as submitted
-            if not local_status == STATUS_SUBMITTED:
+            if not local_status == SUBMITTED:
                 logger.debug("Stack %s not submitted yet.", stack_name)
                 continue
             cf_status = self.get_stack_status(full_name)
diff --git a/stacker/plan.py b/stacker/plan.py
index a8588b1..b78416c 100644
--- a/stacker/plan.py
+++ b/stacker/plan.py
@@ -5,6 +5,8 @@ from collections import (
 )
 import logging
 
+from collections import namedtuple
+
 logger = logging.getLogger(__name__)
 
 INPROGRESS_STATUSES = ('CREATE_IN_PROGRESS',
@@ -12,8 +14,11 @@ INPROGRESS_STATUSES = ('CREATE_IN_PROGRESS',
                        'UPDATE_IN_PROGRESS')
 COMPLETE_STATUSES = ('CREATE_COMPLETE', 'UPDATE_COMPLETE')
 
-STATUS_SUBMITTED = 1
-STATUS_COMPLETE = 2
+Status = namedtuple('Status', ['name', 'code'])
+PENDING = Status('pending', 0)
+SUBMITTED = Status('submitted', 1)
+COMPLETE = Status('complete', 2)
+SKIPPED = Status('skipped', 3)
 
 
 class BlueprintContext(object):
@@ -27,18 +32,22 @@ class BlueprintContext(object):
         self._requires = set(requires)
 
         self.blueprint = None
-        self.status = None
+        self.status = PENDING
 
     def __repr__(self):
         return self.name
 
     @property
     def completed(self):
-        return self.status == STATUS_COMPLETE
+        return self.status == COMPLETE
+
+    @property
+    def skipped(self):
+        return self.status == SKIPPED
 
     @property
     def submitted(self):
-        return self.status >= STATUS_SUBMITTED
+        return self.status.code >= SUBMITTED.code
 
     @property
     def requires(self):
@@ -55,13 +64,18 @@ class BlueprintContext(object):
                 requires.add(stack_name)
         return requires
 
+    def set_status(self, status):
+        logger.debug("Setting %s state to %s.", self.name, status.name)
+        self.status = status
+
     def complete(self):
-        logger.debug("Setting %s state to complete.", self.name)
-        self.status = STATUS_COMPLETE
+        self.set_status(COMPLETE)
 
     def submit(self):
-        logger.debug("Setting %s state to submitted.", self.name)
-        self.status = STATUS_SUBMITTED
+        self.set_status(SUBMITTED)
+
+    def skip(self):
+        self.set_status(SKIPPED)
 
 
 class Plan(OrderedDict):
@@ -85,24 +99,32 @@ class Plan(OrderedDict):
         for i in items:
             self[i].submit()
 
-    def list_completed(self):
-        result = OrderedDict()
-        for k, record in self.items():
-            if record.status == STATUS_COMPLETE:
-                result[k] = record
-        return result
+    def skip(self, items):
+        items = self._parse_items(items)
+        for i in items:
+            self[i].skip()
 
-    def list_pending(self):
+    def list_status(self, status):
         result = OrderedDict()
         for k, record in self.items():
-            if record.status != STATUS_COMPLETE:
+            if record.status == status:
                 result[k] = record
         return result
 
+    def list_completed(self):
+        return self.list_status(COMPLETE)
+
     def list_submitted(self):
+        return self.list_status(SUBMITTED)
+
+    def list_skipped(self):
+        return self.list_status(SKIPPED)
+
+    def list_pending(self):
+        """ Pending is any task that isn't COMPLETE or SKIPPED. """
         result = OrderedDict()
         for k, record in self.items():
-            if record.status == STATUS_SUBMITTED:
+            if record.status != COMPLETE and record.status != SKIPPED:
                 result[k] = record
         return result
 


Test patch:
diff --git a/stacker/tests/test_plan.py b/stacker/tests/test_plan.py
index ffd600d..e5aee2f 100644
--- a/stacker/tests/test_plan.py
+++ b/stacker/tests/test_plan.py
@@ -51,16 +51,24 @@ class TestPlan(unittest.TestCase):
     def test_status(self):
         self.assertEqual(len(self.plan.list_submitted()), 0)
         self.assertEqual(len(self.plan.list_completed()), 0)
+        self.assertEqual(len(self.plan.list_skipped()), 0)
         self.assertEqual(len(self.plan.list_pending()), 4)
         self.plan.submit('vpc.1')
         self.assertEqual(len(self.plan.list_submitted()), 1)
         self.assertEqual(len(self.plan.list_completed()), 0)
+        self.assertEqual(len(self.plan.list_skipped()), 0)
         self.assertEqual(len(self.plan.list_pending()), 4)
         self.plan.complete('vpc.1')
         self.assertEqual(len(self.plan.list_submitted()), 0)
         self.assertEqual(len(self.plan.list_completed()), 1)
+        self.assertEqual(len(self.plan.list_skipped()), 0)
         self.assertEqual(len(self.plan.list_pending()), 3)
         self.assertFalse(self.plan.completed)
+        self.plan.skip('vpc.2')
+        self.assertEqual(len(self.plan.list_submitted()), 0)
+        self.assertEqual(len(self.plan.list_completed()), 1)
+        self.assertEqual(len(self.plan.list_skipped()), 1)
+        self.assertEqual(len(self.plan.list_pending()), 2)
         for i in range(4):
             self.plan.complete("vpc.%d" % i)
         self.assertTrue(self.plan.completed)