Repository: remind101/stacker
Pull request #174 (2016-07-21 21:03:04)

Update to boto3. Add AWS Profile support
Updating to boto3 provides support for the [standard AWS multi-profile capabilities](https://blogs.aws.amazon.com/security/post/Tx3D6U6WSFGOK2H/A-New-and-Standardized-Way-to-Manage-Credentials-in-the-AWS-SDKs).

When I first started trying to use stacker, I spun my wheels for quite a while to figure out how to make it work with our multi-profile support. I eventually stumbled on the closed issue #106, which pointed me in the right direction, but it was certainly big hurdle that I could see stopping a lot of people from trying to pick up a new tool. All of our AWS credentials are [temporary security credentials](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html) so I ended up having to refresh my credentials (with our usual tool) and then do a nasty dance along the lines of `export AWS_SECURITY_TOKEN="$(cat ~/.aws/credentials | grep aws_session_token | head -n2 | tail -n1 | awk '{print $3}')"`. Supporting the modern credentials management with multi-profile support would probably ease adoption going forward.


Patch:
diff --git a/setup.py b/setup.py
index cd741ad..e1efed5 100644
--- a/setup.py
+++ b/setup.py
@@ -8,8 +8,8 @@ src_dir = os.path.dirname(__file__)
 
 install_requires = [
     "troposphere>=1.2.2",
-    "boto>=2.25.0",
-    "botocore>=1.3.15",
+    "boto3>=1.3.1",
+    "botocore>=1.4.38",
     "PyYAML>=3.11",
     "awacs>=0.5.3",
 ]
diff --git a/stacker/actions/base.py b/stacker/actions/base.py
index af42471..dceacfd 100644
--- a/stacker/actions/base.py
+++ b/stacker/actions/base.py
@@ -1,7 +1,8 @@
 import copy
 import logging
 
-import boto
+import boto3
+import botocore.exceptions
 
 logger = logging.getLogger(__name__)
 
@@ -55,35 +56,31 @@ class BaseAction(object):
         self.provider = provider
         self.bucket_name = context.bucket_name
         self._conn = None
-        self._cfn_bucket = None
 
     @property
     def s3_conn(self):
         """The boto s3 connection object used for communication with S3."""
         if not hasattr(self, "_s3_conn"):
-            self._s3_conn = boto.connect_s3()
+            session = boto3.Session(region_name=self.provider.region)
+            self._s3_conn = session.client('s3')
         return self._s3_conn
 
-    @property
-    def cfn_bucket(self):
+    def ensure_cfn_bucket(self):
         """The cloudformation bucket where templates will be stored."""
-        if not getattr(self, "_cfn_bucket", None):
-            try:
-                self._cfn_bucket = self.s3_conn.get_bucket(self.bucket_name)
-            except boto.exception.S3ResponseError, e:
-                if e.error_code == "NoSuchBucket":
-                    logger.debug("Creating bucket %s.", self.bucket_name)
-                    self._cfn_bucket = self.s3_conn.create_bucket(
-                        self.bucket_name)
-                elif e.error_code == "AccessDenied":
-                    logger.exception("Access denied for bucket %s.",
-                                     self.bucket_name)
-                    raise
-                else:
-                    logger.exception("Error creating bucket %s.",
-                                     self.bucket_name)
-                    raise
-        return self._cfn_bucket
+        try:
+            self.s3_conn.head_bucket(Bucket=self.bucket_name)
+        except botocore.exceptions.ClientError as e:
+            if e.response['Error']['Message'] == "Not Found":
+                logger.debug("Creating bucket %s.", self.bucket_name)
+                self.s3_conn.create_bucket(Bucket=self.bucket_name)
+            elif e.response['Error']['Message'] == "Forbidden":
+                logger.exception("Access denied for bucket %s.",
+                                 self.bucket_name)
+                raise
+            else:
+                logger.exception("Error creating bucket %s. Error %s",
+                                 self.bucket_name, e.response)
+                raise
 
     def stack_template_url(self, blueprint):
         return stack_template_url(self.bucket_name, blueprint)
@@ -98,12 +95,24 @@ class BaseAction(object):
         """
         key_name = stack_template_key_name(blueprint)
         template_url = self.stack_template_url(blueprint)
-        if self.cfn_bucket.get_key(key_name) and not force:
+        self.ensure_cfn_bucket()
+        try:
+            template_exists = self.s3_conn.head_object(Bucket=self.bucket_name,
+                                                       Key=key_name) is not None
+        except botocore.exceptions.ClientError as e:
+            if e.response['Error']['Code'] == '404':
+                template_exists = False
+            else:
+                raise
+
+        if template_exists and not force:
             logger.debug("Cloudformation template %s already exists.",
                          template_url)
             return template_url
-        key = self.cfn_bucket.new_key(key_name)
-        key.set_contents_from_string(blueprint.rendered, encrypt_key=True)
+        self.s3_conn.put_object(Bucket=self.bucket_name,
+                                Key=key_name,
+                                Body=blueprint.rendered,
+                                ServerSideEncryption='AES256')
         logger.debug("Blueprint %s pushed to %s.", blueprint.name,
                      template_url)
         return template_url
diff --git a/stacker/actions/build.py b/stacker/actions/build.py
index 22dc93f..3381e69 100644
--- a/stacker/actions/build.py
+++ b/stacker/actions/build.py
@@ -164,11 +164,11 @@ class Action(BaseAction):
         parameters = self._handle_missing_parameters(parameters,
                                                      required_params,
                                                      provider_stack)
-        return parameters
+        return [{'ParameterKey': p[0], 'ParameterValue': str(p[1])} for p in parameters]
 
     def _build_stack_tags(self, stack):
         """Builds a common set of tags to attach to a stack"""
-        return self.context.tags
+        return [{'Key': t[0], 'Value': t[1]} for t in self.context.tags.items()]
 
     def _launch_stack(self, stack, **kwargs):
         """Handles the creating or updating of a stack in CloudFormation.
@@ -233,8 +233,7 @@ class Action(BaseAction):
         Args:
             params (dict): key/value dictionary of stack definition parameters
             required_params (list): A list of required parameter names.
-            existing_stack (`boto.cloudformation.stack.Stack`): A `Stack`
-                object. If provided, will be searched for any missing
+            existing_stack (dict): A dict representation of the stack. If provided, will be searched for any missing
                 parameters.
 
         Returns:
@@ -248,7 +247,7 @@ class Action(BaseAction):
         """
         missing_params = list(set(required_params) - set(params.keys()))
         if existing_stack:
-            stack_params = {p.key: p.value for p in existing_stack.parameters}
+            stack_params = {p['ParameterKey']: p['ParameterValue'] for p in existing_stack['Parameters']}
             for p in missing_params:
                 if p in stack_params:
                     value = stack_params[p]
diff --git a/stacker/actions/diff.py b/stacker/actions/diff.py
index a409366..ce596df 100644
--- a/stacker/actions/diff.py
+++ b/stacker/actions/diff.py
@@ -107,8 +107,8 @@ class Action(build.Action):
     def _print_new_stack(self, stack, parameters):
         """Prints out the parameters & stack contents of a new stack"""
         print "New template parameters:"
-        for param in sorted(parameters, key=lambda param: param[0]):
-            print "%s = %s" % (param[0], param[1])
+        for param in sorted(parameters, key=lambda param: param['ParameterKey']):
+            print "%s = %s" % (param['ParameterKey'], param['ParameterValue'])
 
         print "\nNew template contents:"
         print "".join(stack)
@@ -150,7 +150,7 @@ class Action(build.Action):
         parameters = self.build_parameters(stack)
         new_params = dict()
         for p in parameters:
-            new_params[p[0]] = p[1]
+            new_params[p['ParameterKey']] = p['ParameterValue']
         new_stack = self._normalize_json(new_template)
 
         print "============== Stack: %s ==============" % (stack.name,)
diff --git a/stacker/actions/info.py b/stacker/actions/info.py
index 9dd82e3..914c70a 100644
--- a/stacker/actions/info.py
+++ b/stacker/actions/info.py
@@ -23,5 +23,6 @@ class Action(BaseAction):
                 continue
 
             logger.info('%s:', stack.fqn)
-            for output in provider_stack.outputs:
-                logger.info('\t%s: %s', output.key, output.value)
+            if 'Outputs' in provider_stack:
+                for output in provider_stack['Outputs']:
+                    logger.info('\t%s: %s', output['Key'], output['Value'])
diff --git a/stacker/commands/stacker/base.py b/stacker/commands/stacker/base.py
index 63f4df3..148fab8 100644
--- a/stacker/commands/stacker/base.py
+++ b/stacker/commands/stacker/base.py
@@ -95,7 +95,7 @@ class BaseCommand(object):
             log_level = logging.DEBUG
             log_format = DEBUG_FORMAT
         if verbosity < 2:
-            logging.getLogger("boto").setLevel(logging.CRITICAL)
+            logging.getLogger("botocore").setLevel(logging.CRITICAL)
 
         return logging.basicConfig(
             format=log_format,
diff --git a/stacker/providers/aws.py b/stacker/providers/aws.py
index 36ca58d..007b760 100644
--- a/stacker/providers/aws.py
+++ b/stacker/providers/aws.py
@@ -1,12 +1,12 @@
+import json
 import logging
 import time
 
-import boto
-from boto import cloudformation
-from troposphere.utils import tail
+import boto3
+import botocore.exceptions
 
-from .. import exceptions
 from .base import BaseProvider
+from .. import exceptions
 from ..util import retry_with_backoff
 
 logger = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ def get_output_dict(stack):
     """Returns a dict of key/values for the outputs for a given CF stack.
 
     Args:
-        stack (boto.cloudformation.stack.Stack): The stack object to get
+        stack (dict): The stack object to get
             outputs from.
 
     Returns:
@@ -26,10 +26,10 @@ def get_output_dict(stack):
 
     """
     outputs = {}
-    for output in stack.outputs:
-        logger.debug("    %s %s: %s", stack.stack_name, output.key,
-                     output.value)
-        outputs[output.key] = output.value
+    for output in stack['Outputs']:
+        logger.debug("    %s %s: %s", stack['StackName'], output['OutputKey'],
+                     output['OutputValue'])
+        outputs[output['OutputKey']] = output['OutputValue']
     return outputs
 
 
@@ -51,13 +51,21 @@ def retry_on_throttling(fn, attempts=3, args=None, kwargs=None):
             retry more than attempts.
     """
     def _throttling_checker(exc):
-        if exc.status == 400 and exc.error_code == "Throttling":
+        """
+
+        Args:
+        exc (botocore.exceptions.ClientError): Expected exception type
+
+        Returns:
+             boolean: indicating whether this error is a throttling error
+        """
+        if exc.response['ResponseMetadata']['HTTPStatusCode'] == 400 and exc.response['Error']['Code'] == "Throttling":
             logger.debug("AWS throttling calls.")
             return True
         return False
 
     return retry_with_backoff(fn, args=args, kwargs=kwargs, attempts=attempts,
-                              exc_list=(boto.exception.BotoServerError, ),
+                              exc_list=(botocore.exceptions.ClientError, ),
                               retry_checker=_throttling_checker)
 
 
@@ -83,35 +91,35 @@ class Provider(BaseProvider):
     @property
     def cloudformation(self):
         if not hasattr(self, "_cloudformation"):
-            self._cloudformation = cloudformation.connect_to_region(
-                self.region)
+            session = boto3.Session(region_name=self.region)
+            self._cloudformation = session.client('cloudformation')
         return self._cloudformation
 
     def get_stack(self, stack_name, **kwargs):
         try:
             return retry_on_throttling(self.cloudformation.describe_stacks,
-                                       args=[stack_name])[0]
-        except boto.exception.BotoServerError as e:
+                                       kwargs=dict(StackName=stack_name))['Stacks'][0]
+        except botocore.exceptions.ClientError as e:
             if "does not exist" not in e.message:
                 raise
             raise exceptions.StackDoesNotExist(stack_name)
 
     def get_stack_status(self, stack, **kwargs):
-        return stack.stack_status
+        return stack['StackStatus']
 
     def is_stack_completed(self, stack, **kwargs):
-        return stack.stack_status in self.COMPLETE_STATUSES
+        return self.get_stack_status(stack) in self.COMPLETE_STATUSES
 
     def is_stack_in_progress(self, stack, **kwargs):
-        return stack.stack_status in self.IN_PROGRESS_STATUSES
+        return self.get_stack_status(stack) in self.IN_PROGRESS_STATUSES
 
     def is_stack_destroyed(self, stack, **kwargs):
-        return stack.stack_status == self.DELETED_STATUS
+        return self.get_stack_status(stack) == self.DELETED_STATUS
 
     def tail_stack(self, stack, retries=0, **kwargs):
         def log_func(e):
-            event_args = [e.resource_status, e.resource_type,
-                          e.resource_status_reason]
+            event_args = [e['ResourceStatus'], e['ResourceType'],
+                          e.get('ResourceStatusReason', None)]
             # filter out any values that are empty
             event_args = [arg for arg in event_args if arg]
             template = " ".join(["[%s]"] + ["%s" for _ in event_args])
@@ -121,13 +129,10 @@ class Provider(BaseProvider):
             logger.info("Tailing stack: %s", stack.fqn)
 
         try:
-            tail(
-                self.cloudformation,
-                stack.fqn,
-                log_func=log_func,
-                include_initial=False,
-            )
-        except boto.exception.BotoServerError as e:
+            self.tail(stack.fqn,
+                      log_func=log_func,
+                      include_initial=False)
+        except botocore.exceptions.ClientError as e:
             if "does not exist" in e.message and retries < MAX_TAIL_RETRIES:
                 # stack might be in the process of launching, wait for a second
                 # and try again
@@ -136,10 +141,51 @@ class Provider(BaseProvider):
             else:
                 raise
 
+    @staticmethod
+    def _tail_print(e):
+        print("%s %s %s" % (e['ResourceStatus'], e['ResourceType'], e['EventId']))
+
+    def get_events(self, stackname):
+        """Get the events in batches and return in chronological order"""
+        next_token = None
+        event_list = []
+        while 1:
+            if next_token is not None:
+                events = self.cloudformation.describe_stack_events(StackName=stackname, NextToken=next_token)
+            else:
+                events = self.cloudformation.describe_stack_events(StackName=stackname)
+            event_list.append(events['StackEvents'])
+            next_token = events.get('NextToken', None)
+            if next_token is None:
+                break
+            time.sleep(1)
+        return reversed(sum(event_list, []))
+
+    def tail(self, stack_name, log_func=_tail_print, sleep_time=5,
+             include_initial=True):
+        """Show and then tail the event log"""
+        # First dump the full list of events in chronological order and keep
+        # track of the events we've seen already
+        seen = set()
+        initial_events = self.get_events(stack_name)
+        for e in initial_events:
+            if include_initial:
+                log_func(e)
+            seen.add(e['EventId'])
+
+        # Now keep looping through and dump the new events
+        while 1:
+            events = self.get_events(stack_name)
+            for e in events:
+                if e['EventId'] not in seen:
+                    log_func(e)
+                    seen.add(e['EventId'])
+            time.sleep(sleep_time)
+
     def destroy_stack(self, stack, **kwargs):
-        logger.debug("Destroying stack: %s" % (stack.stack_name,))
+        logger.debug("Destroying stack: %s" % (self.get_stack_name(stack)))
         retry_on_throttling(self.cloudformation.delete_stack,
-                            args=[stack.stack_id])
+                            kwargs=dict(StackName=self.get_stack_name(stack)))
         return True
 
     def create_stack(self, fqn, template_url, parameters, tags, **kwargs):
@@ -148,10 +194,11 @@ class Provider(BaseProvider):
         logger.debug("Using tags: %s", tags)
         retry_on_throttling(
             self.cloudformation.create_stack,
-            args=[fqn],
-            kwargs=dict(template_url=template_url,
-                        parameters=parameters, tags=tags,
-                        capabilities=["CAPABILITY_IAM"]),
+            kwargs=dict(StackName=fqn,
+                        TemplateURL=template_url,
+                        Parameters=parameters,
+                        Tags=tags,
+                        Capabilities=["CAPABILITY_IAM"]),
         )
         return True
 
@@ -160,13 +207,13 @@ class Provider(BaseProvider):
             logger.debug("Attempting to update stack %s.", fqn)
             retry_on_throttling(
                 self.cloudformation.update_stack,
-                args=[fqn],
-                kwargs=dict(template_url=template_url,
-                            parameters=parameters,
-                            tags=tags,
-                            capabilities=["CAPABILITY_IAM"]),
+                kwargs=dict(StackName=fqn,
+                            TemplateURL=template_url,
+                            Parameters=parameters,
+                            Tags=tags,
+                            Capabilities=["CAPABILITY_IAM"]),
             )
-        except boto.exception.BotoServerError as e:
+        except botocore.exceptions.ClientError as e:
             if "No updates are to be performed." in e.message:
                 logger.debug(
                     "Stack %s did not change, not updating.",
@@ -177,7 +224,7 @@ class Provider(BaseProvider):
         return True
 
     def get_stack_name(self, stack, **kwargs):
-        return stack.stack_name
+        return stack['StackName']
 
     def get_outputs(self, stack_name, *args, **kwargs):
         if stack_name not in self._outputs:
@@ -193,18 +240,24 @@ class Provider(BaseProvider):
         try:
             stacks = retry_on_throttling(
                 self.cloudformation.describe_stacks,
-                kwargs=dict(stack_name_or_id=stack_name))
-        except boto.exception.BotoServerError as e:
+                kwargs=dict(StackName=stack_name))
+        except botocore.exceptions.ClientError as e:
+            if "does not exist" not in e.message:
+                raise
+            raise exceptions.StackDoesNotExist(stack_name)
+
+        try:
+            template = retry_on_throttling(
+                self.cloudformation.get_template,
+                kwargs=dict(StackName=stack_name))['TemplateBody']
+        except botocore.exceptions.ClientError as e:
             if "does not exist" not in e.message:
                 raise
             raise exceptions.StackDoesNotExist(stack_name)
 
-        stack = stacks[0]
+        stack = stacks['Stacks'][0]
         parameters = dict()
-        for p in stack.parameters:
-            parameters[p.key] = p.value
-        ret = stack.get_template()
-        template = ret["GetTemplateResponse"]["GetTemplateResult"]
-        template = template["TemplateBody"]
+        for p in stack['Parameters']:
+            parameters[p['ParameterKey']] = p['ParameterValue']
 
-        return [template, parameters]
+        return [json.dumps(template), parameters]


Test patch:
diff --git a/stacker/tests/actions/test_build.py b/stacker/tests/actions/test_build.py
index 3ec0abc..5aa09eb 100644
--- a/stacker/tests/actions/test_build.py
+++ b/stacker/tests/actions/test_build.py
@@ -1,30 +1,26 @@
-from collections import namedtuple
 import unittest
+from collections import namedtuple
 
 import mock
 
+from stacker import exceptions
 from stacker.actions import build
 from stacker.actions.build import resolve_parameters
 from stacker.context import Context
-from stacker import exceptions
+from stacker.exceptions import StackDidNotChange
+from stacker.providers.base import BaseProvider
 from stacker.status import (
     COMPLETE,
     PENDING,
     SKIPPED,
     SUBMITTED
 )
-from stacker.exceptions import StackDidNotChange
-from stacker.providers.base import BaseProvider
-
-
-Parameter = namedtuple("Parameter", ["key", "value"])
 
 
-class MockStack(object):
-    def __init__(self, parameters):
-        self.parameters = []
-        for k, v in parameters.items():
-            self.parameters.append(Parameter(key=k, value=v))
+def mock_stack(parameters):
+    return {
+        'Parameters': [{'ParameterKey': k, 'ParameterValue': v} for k, v in parameters.items()]
+    }
 
 
 class TestProvider(BaseProvider):
@@ -54,7 +50,7 @@ class TestBuildAction(unittest.TestCase):
             {"name": "vpc"},
             {"name": "bastion", "parameters": {"test": "vpc::something"}},
             {"name": "db", "parameters": {"test": "vpc::something",
-             "else": "bastion::something"}},
+                                          "else": "bastion::something"}},
             {"name": "other", "parameters": {}}
         ]}
         return Context({"namespace": "namespace"}, config=config, **kwargs)
@@ -72,7 +68,7 @@ class TestBuildAction(unittest.TestCase):
 
     def test_gather_missing_from_stack(self):
         stack_params = {"Address": "10.0.0.1"}
-        stack = MockStack(stack_params)
+        stack = mock_stack(stack_params)
         def_params = {}
         required = ["Address"]
         self.assertEqual(
@@ -90,7 +86,7 @@ class TestBuildAction(unittest.TestCase):
 
     def test_stack_params_dont_override_given_params(self):
         stack_params = {"Address": "10.0.0.1"}
-        stack = MockStack(stack_params)
+        stack = mock_stack(stack_params)
         def_params = {"Address": "192.168.0.1"}
         required = ["Address"]
         result = self.build_action._handle_missing_parameters(def_params,
@@ -107,7 +103,7 @@ class TestBuildAction(unittest.TestCase):
         )
         self.assertEqual(
             dependencies[context.get_fqn("db")],
-            set([context.get_fqn(s) for s in["vpc", "bastion"]]),
+            set([context.get_fqn(s) for s in ["vpc", "bastion"]]),
         )
         self.assertFalse(dependencies[context.get_fqn("other")])